From cdae61c15c21f45d2bce705b54463d154ba4b0b6 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Sun, 17 Mar 2019 17:29:08 +0100 Subject: [PATCH 001/222] First aproximation to regression --- fda/regression.py | 102 +++++++++++++++++++++++++++++++++++++++ tests/test_regression.py | 52 ++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 fda/regression.py create mode 100644 tests/test_regression.py diff --git a/fda/regression.py b/fda/regression.py new file mode 100644 index 000000000..cec304e1c --- /dev/null +++ b/fda/regression.py @@ -0,0 +1,102 @@ +from sklearn.metrics import mean_squared_error + +from fda.basis import * + + +class ScalarRegression: + def __init__(self, beta, wt=None): + self.beta = beta + self.weights = wt + + def fit(self, y, x): + + y, x, beta, wt = self._argcheck(y, x) + + nbeta = len(beta) + nsamples = x[0].nsamples + + y = np.array(y).reshape((nsamples, 1)) + + Zmat = None + Rmat = None + + for j in range(0, nbeta): + xfdj = x[j] + xcoef = xfdj.coefficients + xbasis = xfdj.basis + Jpsithetaj = xbasis.inner_product(beta[j]) + Zmat = xcoef @ Jpsithetaj if j == 0 else np.concatenate( + (Zmat, xcoef @ Jpsithetaj), axis=1) + + if any(w != 1 for w in wt): + rtwt = np.sqrt(wt) + Zmatwt = Zmat * rtwt + ymatwt = y * rtwt + Cmat = np.transpose(Zmatwt @ Zmatwt + Rmat) + Dmat = np.transpose(Zmatwt) @ ymatwt + else: + Cmat = np.transpose(Zmat) @ Zmat + Dmat = np.transpose(Zmat) @ y + + # eigchk(Cmat) + Cmatinv = np.linalg.inv(Cmat) + betacoef = Cmatinv @ Dmat + + df = np.sum(np.diag(Zmat @ Cmatinv @ np.transpose(Zmat))) + + mj2 = 0 + for j in range(0, nbeta): + mj1 = mj2 + mj2 = mj2 + beta[j].nbasis + beta[j] = FDataBasis(beta[j], np.transpose(betacoef[mj1:mj2])) + + self.beta = beta + + def predict(self, x): + return [sum(self.beta[i].inner_product(x[i][j])[0, 0] for i in + range(len(self.beta))) for j in range(x[0].nsamples)] + + def mean_squared_error(self, y_actual, y_predicted): + return np.sqrt(mean_squared_error(y_actual, y_predicted)) + + def _argcheck(self, y, x): + """Do some checks to types and shapes""" + if all(not isinstance(i, FDataBasis) for i in x): + raise ValueError("All the dependent variable are scalar.") + if any(isinstance(i, FDataBasis) for i in y): + raise ValueError( + "Some of the independent variables are not scalar") + + ylen = len(y) + xlen = len(x) + blen = len(self.beta) + domain_range = ([i for i in x if isinstance(i, FDataBasis)][0] + .domain_range) + + if blen != xlen: + raise ValueError("Number of regression coefficients does" + " not match number of independent variables.") + + for j in range(xlen): + if isinstance(x[j], list): + xjcoefs = np.array(x[j]).reshape((-1, 1)) + x[j] = FDataBasis(Constant(domain_range), xjcoefs) + + if any(ylen != xfd.nsamples for xfd in x): + raise ValueError("The number of samples on independent and " + "dependent variables should be the same") + + if any(not isinstance(b, Basis) for b in self.beta): + raise ValueError("Betas should be a list of Basis.") + + if self.weights is None: + self.weights = [1 for _ in range(ylen)] + + if len(self.weights) != ylen: + raise ValueError("The number of weights should be equal to the " + "independent samples.") + + if np.any(np.array(self.weights) < 0): + raise ValueError("The weights should be non negative values") + + return y, x, self.beta, self.weights diff --git a/tests/test_regression.py b/tests/test_regression.py new file mode 100644 index 000000000..8b45287a6 --- /dev/null +++ b/tests/test_regression.py @@ -0,0 +1,52 @@ +import unittest +from numpy import testing +import numpy as np +from fda.basis import * +from fda.regression import ScalarRegression + +class TestRegression(unittest.TestCase): + + def test_Bspline(self): + + y = [3.170496, 3.162863, 3.168615, 3.101231, 3.079796, 3.052271, 2.903741, 2.952599, 2.969136, 3.082283, 3.045049, 2.973497, 2.960328, 2.893540, 2.981366, 2.847758, 2.706888, 2.652343, 2.611511, 2.569491, 2.609167, 2.559548, 2.667640, 2.602603, 2.434409, 3.062620, 2.930236, 2.784546, 3.413601, 2.434090, 2.515476, 2.428297, 2.617525, 2.415140, 2.158362] + + x0 = FDataBasis(Monomial(domain_range=(0, 365), nbasis=1), np.ones((35,1))) + x1basis = BSpline(domain_range=(0, 365), nbasis=65) + x1coefficients = np.array([[-3.47226935,-4.12302707,-3.4467276,-1.21303734,-5.5114315,-7.6717445,-23.1579052,-13.72184364,-14.9847397,-10.8490052,-10.3883037,-9.05390689,-10.1600572,-6.1934057,-5.5526683,-14.8632838,-18.3053246,-20.7676178,-24.170177,-16.524865191,-20.4535552,-23.15172642,-12.8030614,-8.8914016,-4.60730281,2.4775834,2.962431,-10.9451350,0.7863958,-16.7560345,-27.7366801,-23.2085112,-23.3407679,-25.0389689,-29.65729900], [-3.26637916,-4.58297790,-4.1460407,-1.94262937,-6.1899539,-7.9214086,-21.4158723,-14.95075455,-13.8216701,-11.4012695,-9.8901084,-9.12867690,-8.7346906,-3.5561742,-4.7569655,-11.9478586,-15.8889673,-18.6101565,-26.121923,-12.920320821,-15.1749575,-23.63415049,-11.9708848,-7.0124320,-6.71458251,1.8747273,2.890585,-9.7818307,-0.1281857,-19.7728598,-29.5975420,-27.3694586,-23.5475149,-29.8661207,-32.81546339], [-4.24946460,-7.00508561,-5.6373244,-3.29910816,-8.5584609,-10.8857096,-25.3380862,-15.63839741,-17.4009612,-12.9492214,-12.8163637,-10.60280648,-11.5369087,-8.1825921,-7.2810355,-16.7402119,-20.9800973,-24.9175460,-28.665881,-21.886014236,-24.8321738,-28.97225948,-18.8631975,-16.2648935,-7.87884967,1.2644897,1.941020,-16.3765301,-1.3361429,-20.0834559,-28.3686633,-27.7374558,-27.2815092,-24.6042960,-29.45089474], [-4.95772553,-6.07398338,-5.2195454,-4.00450164,-8.2656450,-10.3423696,-24.6437588,-16.42584865,-16.5522664,-13.6762064,-13.3305344,-11.78776933,-12.4891636,-7.0556666,-6.9179506,-16.0357555,-19.3199597,-22.4525202,-26.317398,-17.066930955,-21.3979173,-28.61933678,-15.9191093,-9.5667938,-4.60705787,3.7438430,4.486262,-9.1304372,1.0799893,-24.6332599,-35.2432669,-29.7497450,-26.3289594,-31.7777296,-33.41553550], [-5.45300180,-8.64207412,-8.0099103,-4.88439500,-10.7539539,-12.7502303,-24.8207517,-18.40409549,-19.4864434,-15.2718882,-14.2787813,-12.93434934,-13.1700724,-9.1324551,-8.9969335,-14.8702058,-17.2057903,-19.2180708,-26.591616,-14.432310623,-17.1211056,-24.06095020,-11.0250744,-6.5606475,-2.66365726,3.7762883,4.064820,-8.0818702,2.5088120,-11.2226698,-22.7604673,-25.8097187,-26.3630554,-29.9524257,-33.02321679], [-4.96060269,-3.95276264,-4.8061315,-1.34987914,-6.3154879,-7.3264702,-22.0749439,-10.79606676,-13.1265425,-10.0385965,-8.3946144,-7.35255718,-7.9404309,-4.2433152,-4.2548323,-13.9698057,-16.8047293,-20.5589898,-26.272942,-14.951273919,-18.4758872,-27.31552604,-11.9018223,-7.1607901,-4.42403762,3.1359431,3.571928,-6.8960090,1.5993438,-18.6360932,-27.5991004,-27.4217339,-26.2509191,-25.5103166,-32.58409920], [-4.10661249,-6.40093221,-6.0379306,-3.51537927,-8.0833744,-9.8749056,-22.8301598,-16.23868656,-16.2649420,-13.2665137,-11.3573036,-11.03773345,-11.9677521,-7.7044819,-7.9973408,-16.1786728,-20.1995362,-23.1711887,-30.335833,-18.004403329,-21.3354820,-30.42556959,-16.2813159,-11.9335541,-4.21911349,3.2897748,3.597416,-11.1712902,0.3420531,-18.5603014,-31.1103631,-31.9458708,-27.4551574,-31.5932330,-33.66106317], [-6.78081992,-8.22213132,-8.5686481,-5.22857223,-10.3992879,-10.7891413,-25.0064402,-15.99626101,-16.5274677,-12.8928170,-13.2490191,-11.40383933,-11.6354531,-7.3320490,-7.3085632,-15.0969846,-16.4316208,-17.5514265,-25.094357,-14.832458974,-16.9018755,-22.76344059,-11.0211189,-5.6202604,-2.73834865,3.8870117,3.995773,-5.1882596,2.9367292,-14.2146189,-25.6885556,-23.7263163,-29.3209250,-28.8730092,-31.73974280], [-4.11901824,-6.16460882,-6.6446683,-3.93735299,-7.9534113,-9.4649555,-21.4398476,-14.56682731,-16.0191517,-12.7490120,-11.9757935,-10.32540845,-10.8292310,-8.6593086,-8.9201365,-12.9457614,-15.8454188,-18.1618264,-26.096698,-12.286633354,-15.5483089,-22.57454221,-10.0865452,-5.4887701,-0.67296693,4.8356040,5.532489,-6.2704778,2.1892239,-12.4113620,-21.9202531,-25.5805716,-24.7134559,-26.6428676,-33.52987507], [-6.18302923,-6.52973154,-7.0279687,-3.47663131,-8.6677619,-8.4482807,-23.1105768,-12.54026163,-13.7297763,-11.1089913,-10.2985547,-8.40961878,-8.7840217,-4.3513495,-4.4705097,-12.1913053,-14.3281825,-18.4157505,-26.553746,-14.095980559,-17.9934149,-25.06402992,-13.7602557,-9.9547269,-0.30885353,5.0014333,4.814417,-6.4700207,2.4020769,-16.6353064,-27.6533078,-27.7655923,-29.1733476,-34.7591168,-32.80978902], [-5.54373766,-4.20947481,-5.7354933,-1.65537288,-6.6938955,-6.9006625,-21.4968306,-10.65833078,-11.4645986,-8.2846020,-7.0049443,-6.08811099,-6.4032607,-4.0788882,-4.0360774,-10.6623901,-13.6720927,-15.3018646,-24.177565,-9.987321912,-12.7824591,-17.33345189,-6.5738572,-1.4622431,1.79318977,5.3257559,5.422894,-1.7319738,3.8239880,-9.1135899,-20.8700500,-21.8394944,-26.1666745,-24.3491470,-33.49927366], [-4.11318768,-5.82550712,-6.2429585,-3.22509804,-7.6127002,-7.5523824,-19.9812265,-10.90933415,-12.9768809,-10.6573174,-10.9780681,-8.54653882,-8.4955718,-5.3643886,-5.5078950,-10.0514994,-10.5320262,-13.8711204,-23.320531,-10.274441524,-12.9214334,-19.08719231,-11.3215348,-8.3229045,0.29876481,5.0853126,4.741263,-6.5964436,1.7358117,-10.4659121,-18.0105529,-19.8489142,-27.6870568,-23.9345876,-33.04222836], [-3.24894386,-2.42058546,-3.7690475,0.03483671,-4.6547764,-3.9904840,-18.3228818,-8.40295958,-8.2922685,-5.2416361,-3.9681146,-3.14952929,-3.5657160,-1.1231098,-0.8334989,-7.6099012,-11.2040219,-12.8277280,-20.981423,-9.455724699,-12.6104695,-18.64318547,-6.4311627,-2.7987663,2.82763863,5.3941577,5.608377,-0.4690863,3.3928046,-11.5899503,-18.9627465,-21.8775098,-24.7752045,-27.0480882,-33.37725903], [-4.64848380,-3.37649958,-4.6122122,-1.63225946,-5.2123227,-4.6822143,-17.6171235,-8.23039864,-8.8295168,-7.4209192,-6.8105057,-4.75328510,-5.1784239,-2.9170251,-2.5124542,-7.1670509,-7.0980237,-10.3974088,-23.248764,-5.575179385,-7.9591323,-15.05774953,-6.7441654,-2.8305539,4.55693319,6.5461787,6.102359,-1.5570469,3.7787339,-4.6788625,-11.3910454,-18.9953345,-26.7285763,-22.9045131,-32.89433818], [-1.70996483,-1.55369455,-2.8430000,0.57406499,-3.0444056,-2.1687876,-16.7925301,-5.33104850,-6.9068912,-4.0200163,-3.6580861,-2.48378561,-2.5366321,-0.3729707,-0.1295860,-4.3782188,-6.5464526,-8.8081666,-18.618307,-4.974302749,-6.9960757,-15.10597215,-3.0701293,-0.4902792,5.53840342,6.9241089,6.833905,0.8558513,3.9778086,-8.0748984,-15.3493139,-17.9673809,-22.3907074,-25.3031244,-30.83367712], [-1.79468731,-0.02003312,-1.2256413,1.40376599,-1.5510013,-0.3946706,-13.1466151,-2.95408687,-3.0471534,-2.4239239,-2.2815159,-0.71713352,-1.1193723,-0.2266671,-0.5229033,-5.0306610,-3.5865254,-7.9279473,-20.092434,-3.219379207,-6.6705790,-14.56729480,-4.7207831,-2.4573009,5.36685741,6.5222566,6.347133,-0.3235591,3.8771446,-5.1022140,-7.8665892,-17.3578142,-24.2554792,-22.1577294,-30.01349068], [0.07407518,0.85332665,-0.2894852,2.04424906,-0.3461414,1.4361722,-10.3639334,-0.71965175,-1.9236248,-0.7737661,0.3916233,2.30763691,1.9505468,3.8639162,4.4419782,-1.1870637,-3.6636184,-6.2289254,-15.190057,-2.590296542,-4.6701189,-8.88482859,-0.5149147,1.0918139,7.30906188,7.3466544,7.435941,2.6224277,4.6058204,-3.1738689,-8.4666394,-12.4637461,-17.5658783,-20.5131640,-27.15246472], [-0.28438190,3.13378000,0.6459162,4.25355975,1.4152649,2.4376976,-9.6761412,0.02187932,-0.6645711,0.3606642,1.8331504,2.71623924,1.9777180,2.9562325,2.4760033,-0.7178319,0.2636696,-2.8254923,-15.700241,1.523912893,-0.5247232,-9.36892030,1.4384727,2.5223161,8.19430823,8.3677549,7.890872,3.9614843,5.3116178,-1.1210625,-3.7869377,-12.2299418,-19.4215572,-18.1622275,-30.13097722], [1.44293648,1.20637153,1.1414142,2.51433115,0.2374252,1.3869209,-10.4010278,-0.17172748,-0.6923887,0.6294272,-0.2253935,2.52578746,2.8334157,3.9582333,4.1414271,1.1889024,3.0634975,-1.1872480,-12.491525,3.500486921,1.3208419,-2.25675567,1.8304585,3.0499592,8.47867705,8.1210759,7.891376,2.9031458,4.8709108,-1.7270202,-3.7179084,-7.9209062,-15.9554640,-18.5566503,-24.72158668], [0.08008628,4.62603760,1.6574296,5.56746031,3.1694946,5.7290775,-6.7963131,5.94101767,5.2886720,5.2493745,7.3038866,8.53525884,8.0872045,7.6511637,8.0454113,4.1708925,5.0142844,1.5327627,-8.991161,4.235801506,3.3218155,-3.46855654,4.3809403,4.7932915,9.59531312,9.0323713,8.557510,5.1095189,5.3236874,0.7534021,-0.9307901,-6.2943292,-17.2206336,-11.6649391,-23.06392179], [2.78862725,4.46691525,3.0606304,5.21979755,3.1755816,4.3574995,-5.5582310,2.70022535,1.8894892,3.8361795,3.5610540,5.65295593,6.2820975,7.2789994,7.6526760,4.3362868,6.4334170,3.5768792,-7.409683,7.498796527,6.0905478,1.42795171,6.1047254,5.8943080,10.64239070,9.3717987,8.686887,5.9250685,6.2871682,1.9758860,1.7488536,-2.4973112,-10.5589460,-11.7528010,-20.47512148], [2.37536345,5.62434193,3.2900996,6.97375430,4.3983096,7.6347339,-3.4162542,8.00257979,7.4132626,8.1088486,9.1393093,10.92889689,10.6055778,9.9285731,9.8731174,5.6424249,6.5194133,3.9606372,-5.211375,6.112069241,5.8400638,3.55102740,6.9166158,5.9703557,11.23398398,10.3671842,9.983211,7.3267346,7.0117462,4.3926516,4.0815507,-0.3518833,-12.3291644,-6.8682372,-18.85221316], [5.34041076,7.34468978,5.9584546,7.33480086,6.4349061,7.4305734,-1.6048484,6.10199134,5.3556576,7.3866334,6.6354619,8.95195965,8.8538611,8.5075497,8.8416568,5.8827597,8.7184020,4.9921868,-5.370467,8.967990652,7.8065643,1.95769240,8.2252454,8.2246540,12.64802533,11.0135827,10.389102,7.9108375,7.5827969,4.2801706,5.4360224,1.3970623,-5.6227141,-3.5623644,-15.44630881], [3.28176795,7.36828640,5.3021310,8.14535938,7.0609248,9.2559964,-1.4139702,7.96683878,7.9103694,9.5586370,9.7368154,12.13015879,12.1327986,11.7280836,12.1211915,7.6827146,9.5789288,8.1014280,-1.631818,9.310356194,9.2869823,6.28306175,9.8987568,8.5086046,13.44910111,11.6133241,10.911988,8.8986644,7.6607970,5.6802894,5.4613415,3.5715359,-7.1527991,-4.9672624,-11.76958566], [7.75155140,10.29680734,8.4807389,10.14091534,9.8194235,11.7937485,3.2392450,12.33600122,10.5322379,11.9219740,11.7313876,13.44365980,13.1557255,12.2654000,12.6904204,9.3095228,11.8651153,7.2863495,-3.060385,12.001831088,9.6890023,5.44537927,9.3907230,9.4121086,13.65387476,12.1393999,11.709668,9.2160558,8.3034706,6.7329852,7.7816949,4.2315014,-2.7899750,-0.3193406,-11.14712818], [5.56176690,10.52993970,8.7837305,10.18420702,9.6440881,12.2005752,0.7421945,11.05953772,10.3303522,12.2653273,12.4098531,14.89023264,15.0492996,14.6922166,14.7698250,10.8691227,13.8377492,11.6502332,1.791342,13.644110341,12.5389694,10.96437779,12.2546772,11.5755271,15.86374940,13.3042481,12.481285,10.7093427,8.8006986,7.1249819,8.6942008,7.8093912,-5.8064297,1.5958094,-10.16968030], [6.63220089,11.37330346,8.6072462,10.49997928,10.5625126,12.4451653,3.3075706,12.62238539,11.5777046,12.6977394,12.2631514,14.63039592,14.6635558,14.0251699,14.4288224,11.1629090,14.6459196,11.5276711,2.273678,13.761167797,13.0456366,8.78365482,12.0426678,11.0456238,14.98367316,13.0236186,12.184201,10.6733458,9.8988681,9.5416716,11.5094529,7.8699777,0.3960318,4.6556723,-6.18452537], [9.94474993,13.17538914,11.6745915,12.31626431,12.7760543,14.8675651,5.6023574,14.27111394,13.5848954,14.8899809,14.6451428,16.72980432,16.3379067,15.7455382,15.6472661,12.5722823,15.7565313,13.0742732,3.928867,14.943047240,13.5679126,13.09743656,13.7961055,13.6256148,18.38740276,14.7712995,13.991393,13.6102432,10.2321143,10.1780225,11.6324619,11.6057604,0.5160710,7.8596259,-4.12770867], [7.95298940,13.32776211,10.5839361,12.08302063,12.2454897,14.2065229,5.5598589,14.15947721,13.6375781,14.5455298,13.4670769,16.34378254,16.9830663,16.8679350,17.5658197,12.9610561,16.1844139,13.3220576,3.212292,16.601176943,14.8454638,9.69278884,13.8335246,12.9028300,16.71624542,14.4569841,13.537529,11.8696096,10.0621205,10.8540281,12.7278689,10.4799096,1.5813731,8.5093407,-2.81115335], [10.36037834,14.07602075,13.0152496,12.89955344,13.8276863,16.3226024,7.6039964,16.26630432,15.5707159,17.2464623,16.2878734,18.46768600,18.2009371,17.9923942,18.0668651,13.6597506,16.1656638,13.9894556,6.381379,15.094620073,14.1063668,13.93774341,13.7476588,13.2985518,18.41391844,15.2054300,14.328058,13.5243290,11.5394049,12.7047158,14.2949071,13.8701586,3.8881370,12.5414838,0.03698253], [13.47850733,16.92666848,15.3935043,14.57903161,17.1674107,17.7221325,10.7265562,17.48829017,17.0370324,17.3153677,16.6020366,19.00109146,18.8311194,17.3119444,17.5059949,13.7594293,17.1248747,16.1337843,7.830939,17.194190575,16.0945118,13.88117480,15.1482737,15.0994684,19.74684128,16.2103856,15.398354,14.0989721,11.1427447,11.3082150,14.0166567,14.0167336,3.7430347,11.8399283,0.61241066], [11.31107280,15.81043104,14.4957744,14.67838726,15.6159496,17.5971764,10.9851155,17.20852704,16.5111542,17.5179530,16.1379163,19.23089090,19.0196816,19.2889282,19.1579313,15.8425635,19.2500009,15.9313145,8.416428,17.826295041,16.4592620,15.33651183,14.2924235,15.0004302,18.77084949,15.3332950,14.386413,13.2126534,11.6359416,12.9261776,14.1209284,14.8367700,6.0016556,11.6242580,2.34870708], [14.04077876,16.90183801,15.4034916,15.24308992,16.5426919,17.9209480,10.3208849,18.15180140,16.9491681,17.7854868,17.1782350,19.70271761,19.8703106,19.1170352,19.6069606,16.0217570,17.9879243,17.6626595,11.265670,17.343334770,16.7174945,16.52088255,15.1506242,14.4410730,18.27433229,16.3213484,15.564584,14.0150659,11.8660692,13.1885469,15.4440148,16.6911346,6.4148856,13.9292708,2.72922471], [13.96011197,17.84660143,17.0868684,15.27698503,17.7629277,18.7257644,12.9619711,17.82269762,17.5178648,18.9016657,17.6344468,20.68491314,20.9394711,20.9067158,20.4893552,18.5216051,20.7195880,17.5025491,11.373626,19.876235249,17.9951314,16.72156595,16.2471478,16.8509148,20.54831743,16.6376503,15.171128,15.6363318,13.2080030,14.4562625,15.6210306,15.8801863,6.9191446,13.6533756,4.71961799], [16.15444153,18.40615832,17.4634452,16.83699108,18.4550642,19.4809814,12.9891387,19.55394652,18.4204521,19.0658287,17.9493436,20.60469503,20.7358576,20.2022430,19.8796224,16.3213001,19.1489248,17.1945481,10.426024,18.111273405,16.7216877,15.63995165,15.5984532,15.5885422,20.29022940,16.8172937,16.381992,14.6084508,12.4339449,13.9482513,16.7472168,17.3519630,8.0507257,15.3977248,4.11916559], [15.28101549,19.48442345,18.4222192,16.58208190,19.0799147,20.4034546,12.3876589,18.59522147,18.1926051,20.0375376,19.1972522,22.06868950,21.8224347,21.9895256,21.6848358,19.2000728,20.3852790,17.8904308,14.053057,19.475717831,18.2568270,16.78706727,15.9400998,16.3474949,20.99706398,17.6615402,16.685725,15.7189565,13.4281759,14.1127217,14.7971319,16.2040310,7.4275184,13.7240786,5.19892681], [15.69065152,18.75547526,17.9915958,17.12135898,18.8208881,19.3166517,12.2036690,19.74584158,18.8212806,19.3681299,17.9126755,21.11940493,20.8732016,20.5117654,20.2076243,17.6172724,19.2698906,17.9560228,11.551862,18.819552959,17.4652208,15.20294296,16.2988245,16.9945964,22.70444597,18.4290770,17.233053,15.8257581,13.0700583,13.6455364,15.0308775,16.7162943,9.5593400,13.2093229,3.70752628], [17.15001985,18.92746695,19.0748894,16.69799225,19.4564126,19.3965989,13.4321683,17.95050223,17.7025542,19.0903596,18.1798292,20.78658305,20.0852676,20.1076003,19.7408712,17.5312490,19.3965073,17.6408784,12.949360,19.267045103,17.3384211,16.44940610,17.0106776,17.2081026,22.28153799,17.9577981,16.596134,16.7169342,13.6946264,15.1131458,15.8989938,16.5692258,7.2831190,14.1153238,3.73424425], [16.23998729,19.76720433,18.5713082,17.66807554,19.2812512,20.1135583,11.8584419,18.66032516,17.5408604,18.9662330,18.1955621,20.98180072,21.0314424,20.5809283,20.2120311,17.6834397,19.1064222,18.3748167,11.259717,19.355017115,17.8459498,16.84927921,17.0056802,17.2961419,21.76814278,18.1896366,17.012443,16.1325775,14.0468356,13.3314775,13.8639636,16.5299751,7.7101595,11.4653459,3.27867569], [16.12794099,18.62645203,18.5889813,16.76488634,18.8051878,18.8448372,12.3499137,17.99374553,17.6427983,18.0840357,17.0743928,19.55967307,19.2094396,19.4899009,19.1091205,16.9168156,19.1695810,16.7506031,13.028893,18.643615656,16.4279645,14.49969338,15.1507141,16.0619212,21.32820615,18.3937492,17.275590,15.8386912,13.5359559,13.7323169,13.5960777,13.9477998,7.6804502,11.7341004,2.43661274], [15.64387040,18.40866364,17.9221674,16.83131354,17.9322092,18.3986046,10.2827070,16.93911381,15.5361702,17.5066819,16.4644769,19.11144408,19.0954613,19.0212898,19.0049135,15.8189698,17.2567711,15.7802607,10.569367,17.667966126,16.3161177,14.91411044,15.5134753,15.5266101,19.80093781,16.9256502,15.576580,14.4846408,13.1863707,12.4177482,12.6791919,14.4827761,6.5688777,11.3674291,1.53545570], [12.79359620,16.23170135,15.5667973,15.21882185,15.8977837,16.1137038,9.9804339,14.63165015,15.2759933,15.8658965,14.5673649,17.57734483,17.5553506,18.6358643,18.6035690,16.3289844,19.1834835,16.4837955,11.604999,18.112800002,15.5488114,13.60014137,13.0722930,14.6448919,19.20124459,17.0284389,15.809294,12.6280466,12.8047561,10.7134301,11.0747193,12.8739780,6.5714528,9.0752035,1.10550596], [15.08516028,17.90006711,16.9448624,16.75246953,17.3658120,17.7740393,8.5004046,16.60187794,15.4982832,17.1825619,16.5865167,19.32721384,18.9354925,19.4891941,19.1172913,14.7509607,15.9151419,13.3530649,9.383003,15.276232027,13.5469180,11.06689384,13.3730589,13.4129192,17.79747334,16.2280456,15.459368,13.3788151,13.0199172,10.5572005,9.6329370,11.3170225,4.6762655,7.6006416,-0.79905241], [10.78289690,14.47669158,13.8238349,14.22815022,14.1748350,13.9237242,8.6794818,14.18556801,12.8436996,14.4271252,13.1989687,16.32627553,16.4047091,17.1963066,17.5517647,14.8077176,16.2256640,12.6032681,7.721803,14.927549353,12.7243074,9.48818538,11.8489597,13.0953215,17.84982374,16.0778327,15.196319,11.5995204,12.1608256,8.1743178,8.1160990,8.0798914,4.9026390,4.9371922,-1.21718016], [13.99273754,15.50079467,14.9949921,14.93749192,15.0021221,14.7899618,5.7749761,13.55375583,12.7265629,13.7577699,13.1902275,15.87948238,15.7716376,17.0053990,16.6843186,11.4158422,12.0558886,10.4797533,6.043461,12.084568801,10.2639706,7.72879852,9.9039655,10.2334800,15.00236833,14.6244534,13.990736,10.0876059,11.8353169,8.2420726,6.7594788,7.3738997,3.3528117,4.9267821,-3.72972257], [10.06200774,12.68446041,11.9690108,12.59243890,12.1598482,11.8617838,4.8832306,10.02672006,10.0419269,11.7653119,11.6248196,13.88519017,13.4475422,14.3819129,14.7652603,11.4433178,13.0217375,10.3340757,6.499276,11.618437418,10.2272854,8.45274256,9.7668102,9.9811098,15.19667146,14.3079556,13.791435,9.3698499,10.8156373,7.9405528,7.8982466,8.0894615,2.0187616,4.9836398,-5.09599482], [12.42557107,13.69006443,13.3810861,13.58810625,13.3593090,12.3241962,4.9797237,11.92330807,10.4580388,11.8202987,11.0180479,13.54418412,13.0921786,14.2968139,14.1485108,8.7477045,9.3914968,7.6969052,3.816280,8.950168951,7.7238879,5.11928599,8.8499366,9.8183456,13.37678680,13.1822331,12.913094,9.6381528,11.1053389,5.4844602,3.0086682,4.5170500,1.0131850,0.1655786,-6.63329227], [9.44780752,11.67777673,11.1595699,11.45592375,11.3407007,10.6600115,1.4960054,8.69990197,7.6467360,9.0586306,9.2832487,11.41895937,10.8415629,11.3491082,11.5185148,8.2885222,9.7320581,7.0680880,1.671535,9.819136785,7.9740234,4.69991069,8.6763420,9.6369055,13.18475449,13.3488565,13.044499,7.8535190,10.1596375,5.2031461,2.9933094,3.9422158,-1.1822174,-0.4132347,-9.90814058], [9.39043577,10.94192260,10.6960418,11.77340371,10.4480535,9.8238850,0.6867065,8.04249841,7.2698812,9.5964186,8.9367242,11.01813080,10.5003052,11.4080792,11.7093164,7.1883869,7.9193858,5.5084212,1.065715,7.266047366,5.8817304,2.51975214,7.8075338,7.8691096,10.81320895,11.1575851,10.773591,7.4466548,9.8080261,2.7868908,0.2950122,1.3185188,-1.7257130,-3.4425979,-9.56914592], [8.01178542,9.04214861,8.9256799,9.24991444,8.7088851,7.4847627,0.4679703,6.87163726,5.7714836,6.3711553,6.0309892,8.33577412,7.7804622,9.4062981,9.1793003,6.4858308,7.0481253,5.1526448,0.654530,6.509578369,6.0674788,3.30286949,6.2532573,7.9582931,10.30007498,11.6994062,11.561345,6.1575390,8.9128411,4.2115341,0.7321533,2.4134667,-3.0694999,-2.6654362,-14.11745894], [6.70396945,8.34282585,7.9767053,9.31930293,7.8859203,7.4284277,-1.1840168,6.39961910,4.8334819,6.9432525,7.1609281,8.73660367,8.6738985,8.8613907,9.6000808,5.0414683,4.9572673,2.9891769,-2.278423,4.900125819,2.9457291,0.01645074,4.2200297,4.3201229,6.72049032,8.6247332,8.441946,3.5066799,7.1129361,0.2935737,-4.5729094,-2.8546655,-3.5243683,-9.8189285,-14.21245958], [6.14896945,7.72286509,7.7987322,8.31789462,6.9760608,6.6089520,-3.5995742,4.68831967,4.4133096,5.5657862,5.7459270,7.46267791,6.7080525,8.2557840,8.0278100,3.8852753,4.9137235,2.8520594,-2.107654,4.377519713,3.6922091,-1.03899512,3.8202570,5.7998064,8.57824177,10.0133692,9.866610,4.8246666,7.9846629,-0.8123301,-7.7006866,-2.2481007,-7.4548679,-10.3211006,-18.08823068], [4.96772535,6.26474216,5.8146683,7.79571183,5.9419961,5.0335052,-2.9789522,4.07393708,2.7225232,4.1376575,4.1878076,5.82289813,5.5493598,6.4895800,6.6995017,3.8372869,2.7270333,0.3629099,-4.915187,1.852587597,-0.7226985,-2.42844406,0.1458071,1.5736269,5.77525450,8.2156388,7.708862,1.7420744,5.7478815,-3.6457478,-9.1510823,-5.8591740,-7.9301543,-14.1171808,-18.90632863], [4.92384866,6.38167282,6.4305248,6.99305653,5.8715601,4.7877773,-5.8851950,2.61816061,2.0261661,3.1965992,3.6241719,5.70969292,5.3333796,6.9578350,7.3324076,1.8301880,0.8898593,-1.8213249,-6.853284,0.001646043,-1.4870871,-5.20716549,0.4230321,2.1330794,5.05490385,7.9248330,7.859559,1.0336071,7.3191185,-5.2532804,-12.9757178,-8.3131788,-9.0634003,-17.2805262,-20.64795819], [3.96407914,4.71065095,4.3080118,6.43604546,3.9173842,2.8902435,-6.0531055,1.72126902,-0.4912465,1.2586537,2.2547166,3.35523779,2.0786009,3.4590790,3.4619226,-1.9597608,-3.9918481,-5.9963992,-11.016791,-3.687180051,-6.0105318,-10.35183414,-5.2703551,-2.4203892,2.67592884,7.2207322,7.171602,-2.0010464,3.7642048,-8.5436299,-13.9537370,-12.4567002,-10.4213426,-18.3215561,-22.63702162], [3.61404935,3.10552156,3.7882917,4.70153038,2.7536109,1.2026077,-10.5849087,-2.51844943,-2.7636949,-0.4980151,-0.6945128,1.27459509,1.1848413,3.6227119,3.5483347,-2.2777595,-2.6964233,-4.8344045,-11.136695,-2.676585930,-4.4787300,-7.96256336,-2.7890556,-0.9226786,3.32245791,6.1738905,6.126539,-0.9169530,4.6324861,-9.3273222,-18.8143571,-12.0028873,-12.8814232,-19.8154777,-24.70115158], [1.20389635,1.35963275,1.9539900,3.22272399,0.6969988,-1.0848004,-9.4023618,-3.14035383,-4.2519269,-2.3740260,-1.7769336,-0.30224286,-1.0101960,1.2902209,1.6925426,-3.3843618,-6.8924891,-10.5917859,-14.943662,-7.702276113,-10.7431091,-15.87038426,-10.1340890,-6.6590733,-0.02827618,5.4667604,5.489291,-6.1010760,1.9533186,-13.7567048,-21.0156906,-19.9199107,-13.4152879,-24.6938089,-25.29142137], [1.97430446,1.64103847,2.2615271,4.02776982,0.6716980,-0.5613943,-13.1318340,-3.16649724,-4.8896414,-2.8625090,-2.2044736,-0.07240792,-0.7719204,2.8236704,2.2188172,-6.2926418,-9.7179623,-13.6609624,-16.610605,-10.048308051,-13.1904432,-18.06822657,-8.3097435,-5.6319387,-2.00153301,3.3523849,3.595994,-5.7210721,3.5740717,-10.7183980,-20.0035235,-18.6790645,-16.4409728,-27.5979646,-28.15301110], [-0.05243898,0.44574724,0.6184050,2.04369706,-0.4364942,-2.6847713,-13.6583680,-5.81638868,-6.6399419,-4.9679900,-4.0517668,-2.95764245,-3.7983902,-1.9636684,-1.4990582,-8.8622091,-11.1604961,-13.2289911,-19.261660,-10.394073226,-12.7112300,-16.43788178,-10.7832181,-6.1363436,-0.93562015,5.1135213,5.235177,-6.4565751,1.8122679,-14.1102531,-25.0830862,-19.8034053,-20.6518419,-22.7562888,-26.91212448], [-0.86600018,-1.91910534,-0.7710663,0.56846325,-3.1323055,-5.6916354,-17.3258726,-9.09444163,-10.0239163,-7.4337484,-7.7800294,-5.67543978,-6.3869941,-1.3676656,-1.6552448,-8.9381856,-12.5047276,-17.4041233,-21.546876,-12.438651712,-16.6830542,-23.32830143,-11.7667705,-8.8113593,-2.47290937,3.6241662,3.797163,-7.8063480,2.4575019,-18.2046602,-26.8233763,-26.4932049,-20.5733666,-29.6131413,-29.33262202], [-2.36681521,-2.28709635,-1.8696157,0.36984720,-3.4646697,-5.4388369,-22.0778966,-10.93681672,-12.4414012,-8.6085119,-6.7665983,-5.49997859,-6.4099822,-3.3492713,-3.2421660,-12.0344871,-15.4417533,-18.8599383,-23.843647,-13.268093879,-16.6119621,-22.98511491,-10.9683242,-5.0338466,-1.49800348,4.2612196,4.379706,-5.1570193,3.2951969,-11.9672283,-23.8122820,-22.2766458,-24.8000403,-25.3475608,-28.94824717], [-2.22202954,-6.01003696,-3.4194820,-3.57855162,-6.4891505,-10.0246229,-21.0748796,-15.81201371,-14.6861033,-11.7707553,-11.6690969,-10.15682677,-10.6388549,-4.1848082,-4.3385541,-10.6883512,-14.0571422,-17.3078938,-22.508033,-14.496587509,-17.4244243,-20.51630999,-13.7884742,-10.8110716,-4.73169849,2.6897291,3.325409,-11.4132849,0.5194959,-17.6908346,-25.2216513,-24.2618054,-21.7975819,-24.8938772,-29.20356636], [-1.99789179,-2.35686639,-2.4379416,0.77563101,-4.5679244,-6.1146924,-21.2243009,-9.55365057,-12.1823680,-8.5695650,-6.8129885,-5.64331998,-6.4717129,-4.6993326,-4.6474165,-15.0633070,-18.7391812,-20.5460257,-25.953251,-13.523940142,-17.1322568,-26.18912109,-10.1002041,-5.5574602,-2.30527988,4.2627107,4.591206,-5.0209158,3.3079860,-14.7589635,-24.9075085,-25.9240261,-23.9952065,-28.6793716,-31.15436023], [-3.63233304,-5.67695663,-4.6308154,-3.16248368,-6.9785392,-9.5818580,-22.6455082,-14.83908584,-15.7423369,-12.9798345,-13.0307011,-11.85702197,-12.7730906,-5.5387227,-5.5124144,-12.9141375,-15.2780961,-18.9509330,-23.509464,-16.778341025,-20.7558574,-23.56480091,-16.2670208,-12.1965631,-7.95351219,0.6510945,1.785466,-14.0185199,-1.4558668,-18.4388269,-27.5324776,-25.1714936,-22.9421665,-24.4895386,-28.18800137], [-3.89561940,-5.19735839,-4.1212035,-2.02522091,-6.2662870,-8.9261642,-21.8459764,-13.35132436,-15.2587448,-11.5253093,-9.5662205,-8.27946771,-8.6474265,-4.9232097,-4.8407829,-14.3521569,-18.7858970,-21.7140720,-26.090517,-17.079833142,-19.4892870,-22.90887280,-12.7777684,-9.4750011,-5.92622484,2.5388369,3.150377,-10.9882030,0.4669286,-15.9150718,-27.1891180,-25.2082659,-24.9632931,-24.4061756,-30.99918375]] ) + x1 = FDataBasis(x1basis, np.transpose(x1coefficients)) + + beta0 = Constant(domain_range=(0, 365)) + beta1 = BSpline(domain_range=(0, 365), nbasis=5) + + functional = ScalarRegression([beta0, beta1]) + + functional.fit(y, [x0, x1]) + + testing.assert_allclose(functional.beta[1].coefficients.round(3), + np.array([[- 0.0034528829, 0.0010604239, 0.0002112618, - 0.0020050827, 0.0051286620]]).round(3)) + + def test_Fourier(self): + + y = [3.170496, 3.162863, 3.168615, 3.101231, 3.079796, 3.052271, 2.903741, 2.952599, 2.969136, 3.082283, 3.045049, 2.973497, 2.960328, 2.893540, 2.981366, 2.847758, 2.706888, 2.652343, 2.611511, 2.569491, 2.609167, 2.559548, 2.667640, 2.602603, 2.434409, 3.062620, 2.930236, 2.784546, 3.413601, 2.434090, 2.515476, 2.428297, 2.617525, 2.415140, 2.158362] + + x0 = FDataBasis(Monomial(domain_range=(0, 365), nbasis=1), np.ones((35,1))) + x1basis = Fourier(domain_range=(0, 365), nbasis=65) + x1coefficients = np.array([[89.59970707, 1.174930e+02, 105.26055083, 1.301337e+02, 99.96350074, 1.005497e+02, -96.65546155, 59.2254168, 42.91552741, 77.96399327, 78.70202100, 117.12657116, 1.110496e+02, 138.82249275, 1.397123e+02, 47.02963944, 47.24424325, -2.889300e+00, -135.38359758, 5.245231e+01, 1.303849e+01, -65.12440445, 4.312490e+01, 76.1634150, 1.671345e+02, 190.26983010, 183.86312129, 73.26888068, 133.787154614, -1.627325e+01, -91.52590710, -95.97501045, -184.4127164, -176.52995213, -315.58275141], [-74.67672652, -7.574673e+01, -85.15643810, -6.601327e+01, -83.87111523, -7.021954e+01, -98.06174409, -76.5849625, -75.88594192, -73.32390869, -67.46233198, -71.17436876, -6.956544e+01, -68.33940763, -6.726469e+01, -71.48143615, -72.30647547, -7.684052e+01, -118.51218788, -6.220087e+01, -6.383014e+01, -84.94995574, -4.925358e+01, -45.6051376, -3.423479e+01, -30.51656702, -28.94391617, -34.56452012, -32.633211110, -4.503430e+01, -50.22893925, -95.51811954, -121.8774966, -98.96479038, -111.60883417], [-115.17887797, -1.497403e+02, -137.09440756, -1.190519e+02, -158.92545942, -1.816266e+02, -221.18086933, -211.4821603, -213.56471876, -200.18276086, -188.11022053, -197.33164610, -2.013817e+02, -170.81595630, -1.706599e+02, -202.60357163, -244.23067752, -2.491603e+02, -239.12452551, -2.306004e+02, -2.438912e+02, -274.86941043, -2.007684e+02, -168.3679298, -1.650695e+02, -92.64319338, -82.89600682, -162.32212326, -75.031989426, -2.089919e+02, -296.89430969, -286.83141439, -211.1464896, -285.79300980, -233.47281556], [5.53204090, -1.130089e+00, 0.52842204, -2.529557e+00, -4.33875191, -7.285767e+00, -9.34989482, -17.3517982, -16.79521918, -13.16131373, -15.10706906, -13.64492334, -1.336251e+01, -6.68886249, -8.759763e+00, -8.59849662, -13.83778943, -9.999166e+00, -3.73673987, -7.144765e+00, -1.224332e+01, -16.78243867, -5.401828e+00, 5.1018233, 1.184882e+01, 12.73523631, 11.89294975, 6.34183548, 10.595544948, 4.244354e+00, -5.78542095, -8.64259411, -9.1706692, 11.14716776, 12.06788481], [4.23416905, -1.094302e+00, 4.44785457, -3.921093e+00, -0.46197323, -6.632285e+00, -7.90683222, -14.9542548, -14.60033008, -9.01383237, -10.77893449, -10.04422090, -1.041400e+01, -3.44100999, -4.604680e+00, -17.36428915, -23.90220629, -2.296112e+01, -10.33599114, -2.145569e+01, -2.587159e+01, -20.52740621, -2.002035e+01, -12.1712595, -1.408536e+01, -1.86240728, -1.03630630, -18.06265481, -4.271425424, -2.053210e+01, -27.49771490, -7.17812126, 6.7410286, 23.62586533, 33.97943929], [-8.77771637, -7.837793e+00, -10.51503459, -5.876606e+00, -9.53902590, -8.950795e+00, -7.96775217, -9.2985688, -10.19042451, -6.61918797, -6.94284275, -7.38161195, -6.848871e+00, -7.05336990, -5.656784e+00, -9.45126385, -4.11766735, -2.002758e+00, -0.96182170, -2.999917e+00, -3.405476e-01, -0.29258681, 3.696487e+00, 2.0932434, -6.022455e+00, -0.15923433, 1.07208902, -1.79542350, 1.574204102, 7.547922e-01, 0.52053437, 5.80414816, 4.3150333, 17.10460123, 9.71564993], [-1.51415837, -2.946328e+00, -1.19506263, -7.124769e-01, -3.57660645, -5.908690e+00, -9.60105427, -8.8099624, -7.68376218, -5.01335967, -4.13543242, -3.76125298, -3.824409e+00, -0.58156824, -3.714378e-01, -5.37422048, -4.73805333, -8.651536e+00, -4.32810391, -3.306290e+00, -4.078670e+00, -6.74524954, -2.018126e-01, -2.8466987, -4.840585e+00, -0.69245669, -0.03545445, -6.60561886, -1.478619562, -5.844820e+00, 1.97271926, -4.13375283, -0.6109633, 4.50542683, 4.00067677], [3.19273792, 1.010561e+00, 1.50781048, 6.207182e-01, 1.26263576, 1.667300e+00, 5.33530014, 2.2976652, 2.18630617, 1.78337371, 2.96173905, 2.06155580, 2.046629e+00, -0.67518222, -1.207833e+00, 3.77542026, 4.80544821, 7.294024e+00, 8.88106241, 5.367792e+00, 8.790006e+00, 8.46637718, 7.963180e+00, 5.9721721, -6.686825e-02, 0.29232727, 0.40864262, 2.66183732, -0.037728177, 1.589603e+00, 1.42045600, 7.86628306, 6.3373820, -0.97133252, -0.09477871], [0.93816015, -1.110971e+00, 0.54196299, -1.461903e+00, -0.84095617, -2.490436e+00, -3.05602692, -3.4011579, -2.51379321, -3.60579532, -3.38203972, -3.76048310, -3.974735e+00, -1.84245223, -1.396258e+00, -3.15492736, -3.05960088, -3.599763e+00, -2.79276108, -3.379715e+00, -2.539994e+00, -3.44772241, -2.340569e-02, -0.8692101, -4.452914e+00, -3.49084735, -1.64765581, -3.77231123, -1.698689830, 1.716089e+00, 8.19121224, 0.52454112, -2.3580422, 8.36202710, 1.43949676], [-0.32883231, 1.819621e+00, 1.00113530, 1.192337e+00, 1.26104761, 2.671245e+00, 3.85644781, 3.3667360, 3.81137706, 1.41261695, 1.71090156, 1.05317196, 1.265023e+00, 1.17510383, 1.676179e+00, 1.22953838, 2.97153947, 2.446238e+00, 0.12602771, 2.270051e+00, 1.120319e+00, -2.38128173, -1.344594e+00, -1.0704945, -1.093017e+00, -0.25113863, -0.48307419, -1.73251272, -1.461257822, -4.698930e+00, -3.90412925, -3.58681205, 2.7387934, -4.72929209, -2.56319254], [0.11601629, 7.660468e-01, 1.50644743, 8.912022e-02, 0.96960619, 1.185724e+00, -3.52462967, -1.2475196, -0.97024300, 0.11505421, -0.12691757, -1.02443267, -1.297848e+00, -1.49376148, -1.013824e+00, 1.17623160, 1.68671168, 3.243256e+00, 0.77789688, 3.327439e+00, 2.786415e+00, 3.41517012, 4.363606e+00, 3.6867715, 2.580380e+00, 1.01183401, 0.89280618, 2.53820860, 0.245348118, 2.748592e+00, 4.03296208, 4.84482840, -0.4626386, 5.75765047, -0.95966081], [1.85054290, 1.655562e+00, 1.48548487, 1.725964e+00, 1.73480788, 1.729848e+00, 2.52923882, 1.1428430, 0.38652710, 1.31141257, 1.44514759, 1.68859090, 1.435771e+00, 1.38363500, 1.634005e+00, -0.29474860, -2.29333598, -3.291515e+00, -1.79756811, -3.803962e+00, -4.081570e+00, -6.87963706, -4.705254e+00, -4.5381878, -1.496349e+00, -0.64357831, 0.04523418, -3.18568596, -1.597410425, -5.711994e+00, -3.26221352, -4.96024097, 2.4892142, -3.47805988, -0.98560047], [-1.92514224, -1.901784e+00, -1.97326589, -4.851519e-01, -2.18719545, -1.403135e+00, -0.22178334, -0.8665782, -0.48234355, 0.41499621, -0.34390321, 0.40307443, 1.644554e+00, 1.81715202, 2.610074e+00, 2.60035175, 2.19658230, 2.930277e+00, 4.80050417, 5.501741e-01, 1.439555e+00, 6.59164448, -2.342761e+00, -3.0053935, -3.550229e+00, -1.55360864, -1.46798140, -4.04960581, -1.591418235, -2.246921e+00, -0.61547511, 5.15601818, 1.9540730, 4.17039396, 2.36170647], [1.00374756, 5.684724e-02, 0.54922198, 5.055470e-01, -0.20610074, -5.449527e-01, 0.72483725, -0.5773061, -0.43579344, -0.36483489, -1.02388369, -1.12723853, -1.187680e+00, -2.11135729, -1.352816e+00, -2.71631074, -2.89474411, -1.125570e+00, 0.11786105, -2.422268e+00, -1.166742e+00, -0.27168351, -1.031490e+00, -1.2818386, -6.442153e-01, -0.32753027, -0.56083303, -1.34982703, -1.382271157, -1.943469e+00, -0.94881097, 0.89495146, 0.3965252, 2.60077158, -1.76550223], [0.52399639, -5.655853e-02, -0.27545732, -1.692510e-01, -0.40510831, -6.253891e-01, -2.18746609, -0.1664321, -0.07768066, -0.20619656, 0.12224883, 0.51634225, 5.523473e-01, 1.03214516, 8.086379e-01, 0.76901682, 0.15913825, 9.792238e-01, 0.85253148, -5.696796e-02, -5.626048e-03, 4.10350760, -1.963309e-01, -1.5263529, 1.032630e+00, 0.48482088, 0.79333156, -1.44946223, -1.268912266, -1.445311e+00, 0.29633510, 2.79746146, 1.8796945, 3.16943020, -0.29738563], [-2.87812864, -6.006869e-01, -1.33765878, -2.417520e-01, -2.21109056, -1.153842e+00, -1.51620001, 0.8903456, 0.11331808, -0.22120068, -0.52819274, 0.46153320, 1.546802e+00, 2.02515279, 2.173945e+00, 3.14379916, 3.51831883, 3.711914e-01, 2.28610877, 1.382766e+00, 6.722253e-01, 1.50799242, -1.736528e+00, -1.4909560, -3.032066e+00, -1.56979669, -1.60074429, -3.32896128, -1.702777138, -2.492388e+00, -2.85806959, -0.10840979, -1.2144304, -1.44284038, -2.47567087], [1.89403282, 3.387828e-01, 1.24981784, 1.476913e-01, 1.14470343, 7.248325e-01, 1.50316611, -0.5275552, -1.31309783, -1.28942861, -1.60728285, -1.37361611, -1.544886e+00, -1.80749183, -2.172358e+00, -1.10586476, -0.59817961, -4.357982e-01, -0.29983525, -3.155637e-01, -8.046187e-01, 2.83630772, -1.069000e+00, -1.5967138, -2.789890e+00, -1.19528112, -0.53442548, -3.30897780, -1.690758341, -2.138268e+00, -1.28369621, 0.26577967, 3.0773312, -2.23526053, 0.83412752], [0.12823011, 4.993544e-01, 1.02232557, 5.454701e-01, 0.94056174, 7.283190e-01, 2.25588692, 1.9841030, 1.43314246, 0.60976371, 0.15388345, 0.94202703, 4.802331e-01, -0.17118836, -1.733193e-01, -1.03902610, -2.31585595, -2.447040e+00, -1.18451441, -2.633862e+00, -3.619202e+00, -1.67590327, -2.588952e+00, -2.3208547, 7.518609e-02, 0.09311304, 0.26970617, -1.80552037, -0.690402996, -2.429605e+00, -5.03001842, -1.50444070, -0.1540092, -5.42013467, -1.10619136], [-0.05352199, -1.036108e+00, -0.77861023, -8.623670e-01, -0.52701735, 1.376449e-03, 1.30578341, 0.1081651, 0.61644607, 0.85691395, -0.76405502, -0.10679753, -1.110967e-01, -0.01503466, -2.454475e-01, -1.07275143, -2.25988039, -2.137842e+00, -1.16810626, -2.641343e+00, -1.978049e+00, -3.19345586, -1.675475e+00, -2.7590442, -1.614353e+00, -1.07275092, -0.31712193, -2.90996441, -1.819662986, -9.108038e-01, -0.44814903, -1.32223011, 4.2705897, 0.10411349, 3.04871719], [-0.63707827, -5.423252e-03, -0.54238986, -5.753046e-01, -0.81002822, -1.812182e-01, -0.72637652, -0.7321933, -0.41937328, -0.89267560, -1.67907984, -1.18129800, -7.633840e-01, -1.11395939, -1.093883e+00, -1.06157417, -1.22123165, -2.262188e+00, -0.23249279, -1.839265e+00, -2.414006e+00, -0.96824711, -1.426166e+00, -1.5246675, 7.014345e-02, 0.34850332, 0.43894888, -0.23563895, 1.003669242, -1.372659e+00, -2.48476002, -0.59369763, -1.4040417, -1.47627477, 1.32651061], [-0.20519335, -6.796379e-01, -0.84645502, -6.354323e-01, -0.74690188, -1.535576e-01, 0.60201689, 0.4156856, 0.42217671, -0.38500919, -0.16557109, -0.43869472, 1.994074e-01, -0.17451086, -1.450471e-01, 0.82473756, 0.83877575, 2.156601e+00, 2.14167561, -2.533763e-01, 8.100568e-01, 2.62674991, -1.358064e+00, -2.0745544, -3.546144e+00, -2.17179370, -2.02087092, -3.13800540, -2.179375003, -3.729858e-01, -0.61674094, 0.02363170, -0.7285647, -1.44872242, -0.33539413], [-1.51120322, -4.099126e-01, -0.70199290, -6.264074e-01, -0.14881164, -3.453822e-01, -2.60262828, -0.4963166, -0.52966599, -0.66123536, -1.58556674, -0.50794510, -9.947995e-01, -1.62037425, -1.196887e+00, 0.14784939, 0.78732258, -9.451537e-02, -0.58266866, -5.885843e-04, -2.225741e-01, -0.57161583, -1.001126e+00, 0.6662864, 1.338666e+00, 1.15763642, 1.09746517, -0.96427466, 0.289711918, -4.878167e+00, -4.79903309, -2.16201221, -4.1199779, -5.17516190, -0.15596139], [0.93772191, 1.434060e+00, 1.46562976, 1.027022e+00, 1.83216687, 2.017265e+00, 1.90922438, 1.2976072, 2.32440414, 1.72987293, 1.43640559, 1.07796853, 8.040374e-01, 0.55450693, 6.050737e-01, -0.73772029, -0.67653405, -5.558536e-01, -0.25247028, -1.500706e+00, -1.225158e+00, -1.12798080, -2.164946e+00, -1.9261836, -2.691497e+00, -1.54005768, -1.18751473, -3.32269485, -2.214593325, -1.897016e+00, -2.33774458, 0.24093075, -0.6937826, 0.17557079, 0.11115402], [-0.06481706, -1.659918e+00, -1.09388609, -1.905815e+00, -1.40679309, -2.245890e+00, -2.23589487, -4.0863735, -3.36119968, -2.54672686, -3.88066869, -3.21323873, -2.951304e+00, -2.81616882, -2.428936e+00, -1.62115870, -1.99948070, -1.944861e+00, -3.54284799, -1.526877e+00, -1.411713e+00, -2.22639667, -1.269234e+00, -0.8733168, -5.888535e-01, 0.43988955, 0.61095815, -0.66937536, -0.194240531, -2.201345e+00, -1.89931801, -1.99023634, -0.9309564, -0.06164975, 0.63689323], [1.29314791, 2.216558e+00, 1.21082603, 2.301156e+00, 2.10492772, 2.058904e+00, 3.39057323, 2.5114900, 2.20853034, 1.45677856, 1.73899204, 1.80547990, 1.249289e+00, 1.11284238, 7.591423e-01, -0.35480926, -1.60016245, -1.093994e+00, -0.73575426, -2.399513e+00, -2.037750e+00, -0.61682787, 1.962567e-01, -0.7811619, -2.499574e+00, -1.19537245, -1.32052778, -1.14084857, -0.305790921, -5.422355e-01, -0.57154810, 0.87568897, 1.1479541, 1.99979013, 0.30593450], [-0.13329982, -1.451984e+00, -1.02249882, -1.567847e+00, -1.49940589, -1.781022e+00, -1.27972730, -4.3121653, -2.67948840, -2.30545248, -2.95382964, -2.60659429, -3.147344e+00, -2.60913233, -2.673216e+00, -2.70395227, -2.64059797, -8.745954e-01, 0.88524607, -3.158829e+00, -1.532546e+00, 0.09266597, -2.914030e+00, -2.6250173, -1.342829e+00, -0.64662735, -0.43686544, -2.09865322, -1.519102101, -2.646319e+00, -1.70747529, 0.89707281, 0.3066956, 3.08995611, -1.19368680], [1.52092905, 3.259918e+00, 2.40838600, 2.871969e+00, 3.09341297, 3.933910e+00, 3.73683573, 5.7564079, 4.35764767, 3.31813996, 4.18205352, 3.10672016, 3.218608e+00, 2.67898919, 2.357314e+00, -0.70056511, -1.68801679, -1.602781e+00, -0.34567583, -2.044594e+00, -1.755141e+00, -0.45440822, -1.538904e+00, -2.5939321, -2.747498e+00, -1.28080182, -0.99409892, -2.84057656, -1.305285838, -9.766209e-01, -0.40284505, -0.27756038, -0.5397107, 4.82378296, 1.24205726], [0.12555626, -5.164036e-01, 0.04631478, -5.777127e-01, -0.22542671, 2.967644e-01, -0.61182539, -0.8243859, -0.06184360, 0.70893457, 0.43232784, 0.15735211, 6.040690e-01, 0.77280898, 7.694384e-01, -0.47463830, -1.03782668, -1.368550e+00, -1.21145518, -1.557196e+00, -2.303430e+00, -1.82390441, -2.169142e+00, -2.4186646, -1.367343e+00, -0.94630230, -0.93509247, -1.53650962, -1.321637726, -3.670951e+00, -4.37924159, -0.45592875, -3.0147021, 0.85525286, 0.58210920], [-0.55294211, 7.259003e-01, -0.13405784, 1.170870e+00, 0.67648687, 7.423729e-01, 0.03089698, 2.1762848, 1.80909252, 1.22782014, 1.48640345, 0.93741620, 9.455015e-01, 0.83294946, 1.167819e+00, -0.34111742, 0.29967272, 6.137995e-02, -1.17558891, 3.534104e-01, 6.210646e-01, -0.58457012, -4.794652e-01, -0.9427772, -1.751084e+00, -1.03318760, -1.32951928, -2.00889268, -1.899263799, -1.009080e+00, -2.77215776, -0.23279707, 0.1536449, 1.80205160, 1.72308629], [-0.84292428, -7.240828e-01, -0.18783875, -2.276676e-01, -0.17319786, -3.188255e-01, -3.19629287, -1.3641001, -0.62180837, -0.41375638, -1.34329317, -1.01205814, -7.984127e-01, -0.62797278, -6.486301e-01, -0.11435523, 0.03625112, -2.078555e+00, -1.65170346, -1.883270e+00, -1.656176e+00, -3.66675034, -3.099800e+00, -2.6033118, -1.004849e+00, -0.55472947, -0.69898102, -2.73888007, -1.580480802, -4.894187e+00, -6.31684492, -3.02072733, -1.6513180, -1.72628994, 1.64966498], [0.58700891, 2.533591e+00, 1.78137004, 2.183785e+00, 2.50339367, 2.351354e+00, 0.69156754, 2.4872038, 1.51129592, 1.83545066, 2.64303869, 1.91865134, 1.576801e+00, 0.75216425, 5.087367e-02, -0.18824949, 1.21772474, 2.707736e-01, -1.90104702, 1.688371e+00, 1.813622e+00, -0.06301625, 1.736327e+00, 1.8251318, 1.531628e+00, 0.56639938, 0.73485081, 1.69305992, 0.329180660, -1.099868e+00, -2.64154447, -2.15795069, -0.6603238, -0.45553474, -0.91044727], [-0.83415793, -9.237381e-01, -0.13605307, -6.640327e-01, -0.69872888, -1.053862e+00, -2.57543265, -2.2838630, -0.86367214, -0.42553165, -0.41312104, -0.41829424, -2.947966e-01, 0.61267035, 3.054356e-01, 0.30492975, -0.88056265, -2.053943e+00, -1.88146693, -1.818735e+00, -1.742755e+00, -1.07010395, -2.092101e+00, -2.2934475, -9.921462e-01, -0.65940619, -1.29734698, -2.14460743, -0.149219082, -1.123322e+00, -0.85028388, -1.09243236, -1.7484155, 1.07965678, -1.41680460], [-1.09559400, 8.752225e-01, 0.11894841, 1.412295e+00, 0.35075438, 1.177684e+00, 0.22637718, 1.4511574, 0.64680082, 1.02591542, 1.93749832, 1.52391763, 1.727282e+00, 2.24265094, 2.505160e+00, 2.25250414, 0.91597471, 1.836988e-01, -0.87855461, 2.178514e+00, 1.143963e+00, -0.21672121, 1.370155e+00, 0.6991980, 9.343378e-01, 0.33245579, 0.01881070, 1.36815233, -0.218940400, 9.519239e-02, -0.91836698, -2.25471571, 0.8385579, -0.51322330, -1.57342169], [-0.02387740, 1.928508e+00, 0.99791004, 2.009739e+00, 1.81165891, 2.394173e+00, 1.78346542, 2.2227813, 2.03253527, 1.61895789, 3.51168616, 3.13739560, 2.845314e+00, 3.71095248, 3.776803e+00, 1.39810535, -1.57526756, -3.222592e+00, -2.16501910, -2.499120e+00, -2.771815e+00, -3.00282112, -2.332177e+00, -2.3011830, -1.768804e+00, -0.72479856, -0.28197745, -1.58254826, -0.546327301, -1.085810e+00, -2.00566426, -2.90734205, 0.6635186, -3.25554509, -0.49570402], [0.70193942, 8.983500e-01, 0.46959549, 8.356255e-01, 1.43694426, 1.164194e+00, 2.69286894, 0.2766771, 1.08325737, 0.63212413, 1.07172161, 1.12083776, 1.142423e+00, 0.98642962, 8.381407e-01, 2.14839871, 2.40860798, 1.596700e+00, 2.00761769, 1.029942e+00, 5.332955e-01, 1.00251691, 1.477372e+00, 0.8825888, 7.543783e-01, -0.71170156, -0.66228059, 0.72120461, -0.112047972, 8.918786e-01, 2.81122334, 1.18774046, 1.6183223, 1.43545417, 1.96891353], [0.39181669, 1.288237e+00, 1.60737757, 7.814422e-01, 1.88927026, 1.701027e+00, 1.06322454, 2.1590953, 1.95923291, 1.52144924, 2.96889237, 2.84039247, 2.529072e+00, 1.72091607, 1.980772e+00, -0.26682328, -2.36439358, -3.115120e+00, -1.44706064, -3.195354e+00, -3.193999e+00, -4.53016456, -1.851243e+00, -2.2578876, -1.250965e+00, -1.00037953, -1.02811886, -1.91047284, -0.791022341, -1.868363e+00, -0.79503202, -3.12065068, 1.0982350, -0.98791805, 1.17235759], [-0.84975042, -6.042669e-01, -0.69995578, -3.855678e-01, -0.81858779, -4.477763e-01, -0.48064829, 1.0503627, -0.34075923, 0.05409725, -0.70536980, 0.15810693, 7.651033e-01, 0.53022488, 4.582444e-01, 2.25153660, 1.85012782, 4.967006e-01, 2.25063341, 6.230297e-01, 5.676067e-01, 0.50763531, 8.492886e-01, 0.4962994, -1.358876e+00, -0.44359800, -0.17973976, -0.38368966, 0.129729312, 2.040848e+00, 3.53169871, 0.72533970, 1.0386347, 0.49064948, 0.02608175], [0.92537534, 7.075020e-01, 0.82971380, 1.044658e+00, 0.46269672, 1.620570e+00, -0.93178459, 0.5179339, 0.88022346, 0.99439406, 1.47013324, 1.47939859, 1.497188e+00, 0.92313668, 4.794219e-01, -0.38780418, 0.19988400, -1.184432e+00, -1.56908773, -4.425361e-01, -1.032698e+00, -1.26721281, -2.000995e+00, -2.6358795, -2.502450e+00, -1.26815114, -0.77843387, -3.36357365, -1.536487004, -1.919820e+00, 0.64204764, 0.35792749, -1.4945154, 1.43738486, -0.58339537], [1.20178509, -2.000682e-01, 0.01693073, 2.133457e-01, 0.30763788, -2.667839e-01, 1.08970769, 0.2702089, -1.63401658, -1.05500883, -1.42481123, -1.28505382, -9.258686e-01, -1.41616250, -1.843747e+00, 0.11481250, 0.93181784, 5.366704e-01, -0.89659028, 2.318438e+00, 1.872510e+00, 1.74334003, 2.854597e+00, 3.3555994, 2.925989e-02, -0.48783305, -0.47083650, 0.74844394, 0.559798914, 4.525265e+00, 4.58477169, 1.99706784, 1.5080084, 0.06314047, -0.59944915], [2.07722881, 5.341312e-01, 0.77947626, 6.037828e-01, 1.17211573, 5.383573e-01, 0.77166599, 0.3726998, 0.25550811, 0.59648554, 0.38012092, 0.07006915, 5.868517e-03, -0.74238151, -1.497909e+00, 0.47577400, 1.06051627, 1.617059e+00, 1.80032305, 7.479881e-01, 2.043964e+00, 2.52557356, 1.219913e+00, 0.4803534, 4.667791e-01, 0.38887412, 0.38644969, 0.70774447, -0.336216608, 1.632456e+00, 3.00018553, 3.45481265, 0.7790756, 4.13703668, 0.06337106], [-2.24989837, -4.302763e-01, -1.74929544, -3.740111e-01, -1.07219551, -1.335091e-01, -3.16035682, -1.6740220, -1.90681805, -0.82402832, 0.16596253, -0.05064610, 9.294415e-02, 0.48398348, 6.499076e-01, 0.47855573, 0.35938927, 2.321464e+00, 2.41447364, 1.441026e+00, 2.963557e+00, 2.82913177, 3.384105e+00, 3.4473668, -4.024473e-01, -0.17129249, -0.29503759, 1.54836250, 1.513930909, 6.481393e+00, 4.51432690, 4.07080734, 0.8138094, 1.81949946, 0.57143108], [-1.01656994, -8.890316e-01, -1.29461899, -7.901383e-01, -0.68447103, -1.201129e+00, 0.28967262, -1.1334414, -1.77287724, -1.21672304, -1.69278076, -1.21148979, -5.146720e-01, -0.64248992, -4.680204e-01, 0.90215798, 1.42263597, 2.830218e+00, 1.05815930, 1.898014e+00, 3.858844e+00, 4.42466365, 2.854507e+00, 2.4693304, -2.348306e-02, -0.25772640, -0.37533300, 0.18576537, -0.361076485, 3.383409e+00, 3.70668446, 4.64448898, 0.2705007, 6.36882615, 0.10410477], [-0.50992353, -1.184197e-01, -0.12051278, 2.810264e-01, -0.11147047, 3.340698e-01, 0.03633611, 0.4288639, 0.58526527, 0.50172923, 0.90255473, 0.81175872, 4.213462e-01, 0.56000628, 2.846546e-01, -0.22039297, 0.48549177, 3.008412e-01, 0.16184718, 9.609462e-01, 1.354041e+00, 2.34607497, 2.872148e+00, 4.4758467, 1.733022e+00, 0.19684940, 0.18258232, 3.00324295, 0.889483650, 5.485918e+00, 3.83386430, 3.46360979, -1.4687842, 2.54672368, -0.27029831], [0.45902810, 1.699793e-01, 0.21554644, -5.523539e-01, 0.57907943, 3.932309e-01, 1.06041236, 0.6304128, 1.47611716, 0.54732013, 0.70397469, 0.91704818, 1.414214e+00, 0.11566881, 2.830642e-01, 1.81901051, 1.94932693, 2.850322e+00, 0.94791735, 2.498273e+00, 3.156392e+00, 6.11465651, 2.362363e+00, 2.9962383, 9.802733e-01, 0.57738645, 0.65135804, 1.07888362, 0.240927614, 2.919394e+00, 2.38432923, 3.48394353, 0.1052820, 4.75600853, 0.71530482], [-0.24349431, -8.439431e-01, -0.66341302, -4.697805e-01, -0.71829961, -2.732005e-01, -0.84448579, -0.8273185, -0.23995555, -0.11808013, -0.08633229, -0.39362060, -3.178583e-01, 0.17521182, -1.559364e-01, -0.38649251, -0.32610195, 9.177964e-01, 0.63200688, -2.276129e-01, 3.370388e-01, 3.90440681, 6.545717e-01, 1.4367193, -9.454337e-03, -0.29152578, -0.22592465, 1.39639653, 0.849624428, 2.994090e+00, 1.29402269, 2.44582665, -1.0828134, -0.94532233, 0.40325778], [-1.39223110, -1.622055e+00, -1.20778530, -1.069178e+00, -1.43637684, -1.159123e+00, -1.22136458, -1.6760825, -0.11519447, 0.25278374, -0.27100938, -0.23787892, 2.735731e-01, -0.17959874, 2.264140e-01, 0.19796455, 1.21407987, 1.426652e+00, 0.11194166, 8.415828e-01, 1.649631e+00, 3.54137935, 1.501209e+00, 0.3463514, -8.164126e-01, -0.88062879, -0.72262526, -0.01791845, 0.235655888, 2.037726e+00, 2.34970417, 1.09734909, 0.0682573, 0.60407452, 0.90443122], [-1.02392565, -5.673491e-01, -0.70964602, -3.687754e-01, -0.36096089, -8.638283e-01, -1.77941475, -2.7969361, -1.42330776, -1.55866903, -1.61706674, -1.73253802, -1.134115e+00, -0.04767659, -3.778682e-01, -0.20021839, 1.00107533, 1.643225e+00, -0.20567176, 1.323667e+00, 9.398355e-01, 0.43918670, 5.056253e-01, 0.7109244, 4.193428e-01, 0.06621289, -0.24312743, -0.07837749, -0.140794080, 2.557637e-01, -1.59921821, 0.21465080, -0.8936323, -3.11880272, -1.03891844], [-0.82870195, -2.099606e-01, -0.15920175, -3.192975e-01, -0.58927883, -9.958981e-01, -0.45918863, -2.1210116, -0.80747556, 0.17178359, -0.18338013, -0.35841963, 7.590088e-02, 0.31752054, 4.301735e-01, 1.37493985, 1.52285479, 2.249923e+00, 0.67516745, 1.533434e+00, 2.182756e+00, 0.76057515, 1.629477e+00, 1.3988561, 9.473159e-01, 0.31546383, 0.03214408, 0.94171111, 0.339876393, -2.163501e+00, -1.41146876, -0.71298005, -0.6075998, -2.80944526, 0.06488881], [1.28418890, 3.649906e-01, 0.59380554, 1.759908e-03, 0.89014875, 9.566813e-01, 0.03879258, -0.4865600, -0.46720751, 0.21530793, 1.34206318, 0.19911945, 1.026378e-02, -0.05133407, 3.608472e-01, 0.60363157, 0.03253768, 3.366076e-01, 0.68156764, -5.036516e-01, -5.293649e-02, 1.04943224, 3.554001e-01, -0.5084141, 2.388126e-01, 0.07516278, 0.15713984, -0.12204924, -0.349544186, -2.160509e-01, -2.09680497, 0.76599707, -0.2259136, 0.61532440, 0.16331847], [1.00794962, -3.810249e-01, -0.12973934, 2.508333e-01, -0.17568826, 8.686412e-02, 1.37622548, 0.8415755, 1.18632403, 0.56813310, 0.86185305, 0.28815620, -2.965147e-02, 0.72854260, 3.082082e-01, 0.50788173, -0.22606523, -4.097192e-01, -0.15607617, -1.394077e+00, -8.780649e-01, -2.35070263, -1.711104e+00, -1.6499690, -3.468064e-01, 0.33841614, 0.39654237, -1.05243700, -0.331145258, -1.292601e+00, -1.25903999, -1.08741339, 1.0970864, -1.42529199, -0.72820593], [-1.58154333, -1.390164e+00, -1.20625557, -1.409198e+00, -1.04509734, -1.179929e+00, 0.07190697, -1.6489419, -0.95285604, -1.53267498, -2.04393750, -1.44254455, -1.050924e+00, -0.27768119, -2.803571e-03, 0.88536754, 1.00612466, 6.708885e-01, -0.17207077, 3.065130e-01, 9.812993e-01, 1.59942069, -8.212277e-01, -0.5297174, -1.261708e-02, -0.05160913, -0.17776824, -1.94493722, -0.547986131, -7.006174e-01, -2.50788964, -0.52733872, 0.7161020, -1.67369274, 1.50037798], [-0.45628757, -1.512576e+00, -1.24477454, -1.315945e+00, -1.36949210, -1.196824e+00, -0.09191878, -1.4566100, -1.23237563, -1.32163296, -1.26507453, -0.77997970, -4.037119e-01, 0.13454366, -1.144565e-01, 1.05612140, 0.10194231, 2.284894e-01, 0.58973367, 3.054403e-01, 5.821388e-01, 0.54479869, -7.658625e-01, -2.1371981, -7.184237e-01, -0.05619105, -0.23008535, -1.91233567, -1.059662431, -5.892829e-01, -0.07389768, -0.87797601, -1.1497557, -3.22940075, -0.51134054], [2.24355893, 2.987791e-01, 1.57003140, -6.577640e-01, 0.95599659, -3.234701e-01, 1.03488812, -1.6822629, -0.49675688, -1.40446335, -1.68761302, -1.80113977, -1.910319e+00, -1.31372456, -1.796134e+00, -0.78463456, 0.13496712, 3.548790e-01, -0.15189028, 3.527213e-01, 3.898108e-01, 1.61126487, -9.367634e-01, -0.1448268, 5.824714e-01, 0.86455257, 1.03595041, -0.73132245, -0.581587321, 4.870423e-01, 0.69273813, 0.40620599, 0.4264222, 2.22542172, -0.64908154], [0.03151138, -1.429253e+00, -0.33020856, -8.769927e-01, -1.05542294, -1.479335e+00, 0.28840750, -1.8588508, -1.91150763, -1.58731427, -2.45159863, -2.14016373, -1.856729e+00, -1.45426533, -1.499989e+00, -0.14273784, 0.65881620, -4.381416e-01, -2.25086798, 6.856435e-01, 1.284126e-01, 0.16678078, -7.691342e-01, -1.6276582, 3.319708e-01, -0.06524522, -0.13800628, -1.80628763, -0.626905087, 1.746483e-01, 2.40221924, -0.43184965, 1.4377209, -1.81368469, -0.63079462], [-0.19420235, 1.017261e+00, 0.44046808, 2.235912e-01, 0.82711704, 1.488388e-01, 1.11226175, -0.6765021, -0.50506171, -0.57656726, -0.11680901, -0.76164050, -1.120442e+00, -1.41198785, -1.707052e+00, 0.03169615, 0.42995819, -6.548027e-01, -0.92319645, 9.372992e-01, 4.472379e-01, -0.64736446, 2.350966e-03, 0.8167789, 1.532516e-01, 0.26300417, 0.27715292, -0.22968922, -0.318246824, 7.685206e-01, 1.26979359, -0.60094669, 1.6978479, 2.50160157, -1.09154674], [0.40872749, 5.316905e-01, 0.07574449, 3.738896e-01, 0.49030229, 2.154546e-01, 1.20505168, -0.3998987, 0.45921862, 0.12764587, 0.24036029, 0.04300324, 2.611705e-02, -0.38701871, -7.228944e-01, -0.06229737, 0.02990214, -1.022368e+00, -1.27149324, 1.078780e+00, 4.584915e-01, -2.08859968, 6.685534e-01, 0.1185499, -6.889925e-01, -0.50159233, -0.62806797, -0.40109349, 0.478865976, 1.012569e+00, 1.74002151, -2.58437083, 0.2119228, -1.26496072, -0.83611301], [-1.20693909, -8.648244e-02, -0.68445745, -4.231141e-02, 0.07030813, 2.977115e-02, 1.61514664, 0.3613738, 0.98519906, 0.56038201, 0.68799786, 0.25726847, 2.943262e-01, 0.03265916, 4.871384e-01, 1.08699337, -0.14340130, 1.158565e+00, 2.67213822, -1.448469e-01, -3.759568e-01, 1.47032173, 9.272117e-01, 0.6709135, 1.699794e-01, 0.30539670, 0.64835640, 0.01487555, -0.728060109, -1.132007e+00, -0.59876173, -0.01768361, 1.2546856, 0.47877009, -0.46534162], [0.74070647, 1.881216e-01, 0.10590741, 6.856033e-01, -0.01494294, 4.620404e-01, -0.37088003, -0.3991986, 0.63966992, 0.62867809, 1.13309925, 1.03074702, 1.180520e+00, 0.94804892, 3.586752e-01, -0.48021134, -1.97926806, -3.987384e-03, 0.15562854, -3.272787e-02, 9.576702e-01, 0.42574563, 2.626444e+00, 2.4926891, 1.194390e+00, 0.21078992, 0.58478490, 2.17276930, 1.311293068, 9.013753e-01, -0.58093539, 0.25607094, 1.0480987, -0.19546428, -1.78147390], [0.58252591, 4.277163e-01, 0.90430340, 1.034826e-01, 0.58591696, 5.142170e-01, 0.95038449, -0.3056090, 0.30011349, 0.97574674, 0.44413519, 0.40519577, 6.835409e-01, 2.13472092, 2.159293e+00, 1.55883723, 0.17847583, -1.351055e-01, 1.94473047, -4.080991e-01, -9.536895e-01, 2.11378246, 9.719330e-03, -0.1195651, -6.975778e-01, -0.94429705, -0.85992101, -0.02517769, 0.007567667, -1.473490e+00, -2.56068088, 0.23898731, 2.6713763, -1.15294033, 1.89343762], [0.51086899, 1.945461e-01, 0.35563999, 1.376437e-01, 0.41130673, -1.761141e-01, 0.70332114, -1.5772770, -0.40116904, -0.30775824, -0.18106109, -0.35859266, 4.523594e-02, 0.08296053, -2.474059e-01, 0.99918091, 0.16696234, -2.655998e-01, 0.11855061, 1.604159e+00, 1.353651e+00, 0.76703312, 1.713040e+00, 2.2453633, 1.112115e+00, 0.58666241, 0.77868287, 1.85247340, 1.005655963, 2.964966e-01, -0.75506257, -0.46511248, 2.5370339, 0.12240512, -0.76445357], [1.14540932, 4.230023e-01, 0.82329579, 2.522589e-01, 0.40010288, -1.159966e-01, -0.58847402, -1.4912045, -0.91540235, -0.70744602, -1.63973010, -1.16779949, -6.924016e-01, -0.12211442, 2.142196e-01, -0.95961729, -0.03517539, 6.954538e-01, -0.07720068, -7.311228e-01, -2.358224e-01, 0.61405116, -1.503198e-01, -0.9694861, -1.794021e+00, -0.68244899, -0.67482556, -1.16556735, -0.013233124, -1.698246e+00, -1.36983520, 0.31130760, 0.7389527, -0.84510408, 1.18858326], [1.74285071, 2.506958e+00, 2.76422771, 1.654996e+00, 2.28409145, 2.352266e+00, 1.72266916, 0.9046326, 2.32138310, 2.34904150, 2.85547750, 2.73975738, 2.621758e+00, 2.49867607, 2.462475e+00, 2.05542418, 0.62659544, 1.181264e+00, 0.44256888, 1.147806e-01, 1.122830e+00, 0.21694589, 7.742090e-01, 0.9181116, -1.400702e-01, -0.13253731, -0.61253172, 0.69978709, -0.192494238, -3.563360e-01, -1.27044350, -0.19247570, 0.8062723, -3.03513839, -0.75634886], [-1.85078404, -7.183344e-01, -0.38240584, -9.209318e-01, -0.55601263, -1.572229e-01, -0.30486141, -0.5511934, 0.44767040, -0.17350576, -0.27735373, 0.08864750, -9.293620e-02, 1.13317491, 7.132884e-01, 1.04196753, 2.27408855, 1.160518e+00, 1.79245281, 6.472166e-01, 7.166789e-01, 1.51546460, 1.067069e-01, 0.1304825, 5.333138e-01, 0.32225323, 0.11127284, 0.21314949, 0.462165374, 4.007278e-03, -0.03874602, 0.92968477, -2.1448894, 0.55525031, -0.53638872], [-0.24538532, 1.038213e+00, 0.22348443, 1.524884e+00, 1.02875904, 1.374557e+00, 0.95412042, 0.8511914, 1.30296742, 0.92884887, 1.71803275, 1.22633650, 1.339939e+00, 0.42608840, -1.901805e-01, -0.20046471, -0.98173732, -8.768400e-01, -1.65674072, -1.939138e-01, -4.038453e-01, -0.69586081, -7.000077e-01, -0.7295975, -2.086565e-01, 0.17521970, 0.05716848, 1.18548477, 0.272045330, -3.792776e-01, -0.73572734, -1.67429640, -1.2667567, 0.43780133, -0.54679839], [-0.20314284, -4.928191e-02, -0.47170388, -3.994800e-01, -0.01959643, 7.333726e-02, -0.04472905, 2.4390476, 1.39536759, 0.19688113, 0.13007568, 0.34144884, 3.527637e-01, 1.15212885, 7.171617e-01, 0.68313280, 0.75987149, -2.710371e-01, 0.14343316, 9.875637e-01, 3.633765e-01, -1.24790369, 5.623962e-01, 0.4395609, 9.299851e-01, 0.28522147, 0.46285969, 0.72755880, 0.186921131, -7.914515e-01, 0.01194463, 0.70348229, 0.3314508, 0.56448351, -2.60371925]]) + x1 = FDataBasis(x1basis, np.transpose(x1coefficients)) + + x1.plot() + + beta0 = Constant(domain_range=(0, 365)) + beta1 = Fourier(domain_range=(0, 365), nbasis=5) + + functional = ScalarRegression([beta0, beta1]) + + functional.fit(y, [x0, x1]) + + testing.assert_allclose(functional.beta[1].coefficients.round(3), + np.array([[0.0006011817, -0.0014284904, 0.0041845742, -0.0160814209, 0.0028040342]]).round(3)) + + +if __name__ == '__main__': + print() + unittest.main() From f5dfbda1e4731e5a1e5a706d54b33920a914fa6f Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Mon, 18 Mar 2019 07:50:35 +0100 Subject: [PATCH 002/222] Added the __add__ impl for same basis or nums --- fda/basis.py | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/fda/basis.py b/fda/basis.py index 33cc0dc33..020cae35d 100644 --- a/fda/basis.py +++ b/fda/basis.py @@ -1270,7 +1270,8 @@ def basis_of_product(self, other): raise ValueError("Ranges are not equal.") if isinstance(other, Fourier) and self.period == other.period: - return Fourier(self.domain_range, self.nbasis + other.nbasis - 1, self.period) + return Fourier(self.domain_range, self.nbasis + other.nbasis - 1, + self.period) else: return other.rbasis_of_product(self) @@ -2133,18 +2134,29 @@ def __getitem__(self, key): if isinstance(key, int): return self.copy(coefficients=self.coefficients[key:key + 1]) - else: return self.copy(coefficients=self.coefficients[key]) + def add_samples(self): + if self.nsamples == 1: + return self + return self[0] + (self[1:].add_samples()) + def __add__(self, other): """Addition for FDataBasis object.""" + if isinstance(other, FDataBasis): + if self.basis != other.basis: + raise NotImplementedError + else: + return FDataBasis(self.basis.copy(), + self.coefficients + other.coefficients) - raise NotImplementedError + coefs = self.coefficients.copy() + coefs[:, 0] = self.coefficients[:, 0] + numpy.array(other) + return FDataBasis(self.basis.copy(), coefs) def __radd__(self, other): """Addition for FDataBasis object.""" - return self.__add__(other) def __sub__(self, other): From f3a0411342ef42e9188ffc2ebe4c51fb89a10ddd Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Mon, 18 Mar 2019 07:51:40 +0100 Subject: [PATCH 003/222] Added the __sub__ impl for same basis or nums --- fda/basis.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/fda/basis.py b/fda/basis.py index 020cae35d..7613945db 100644 --- a/fda/basis.py +++ b/fda/basis.py @@ -2161,13 +2161,16 @@ def __radd__(self, other): def __sub__(self, other): """Subtraction for FDataBasis object.""" - - raise NotImplementedError + if isinstance(other, FDataBasis): + return (self.__add__( + other.copy(coefficients=(-1 * self.coefficients)))) + else: + return self.__add__(-1 * other) def __rsub__(self, other): """Right subtraction for FDataBasis object.""" - - raise NotImplementedError + return ((self.copy(coefficients=(-1 * self.coefficients))) + .__add__(other)) def __mul__(self, other): """Multiplication for FDataBasis object.""" From 95b7685b7edeacf726790efdb5106cc576f3740d Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Mon, 18 Mar 2019 07:55:42 +0100 Subject: [PATCH 004/222] Added the __mul__ impl for same basis or nums --- fda/basis.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/fda/basis.py b/fda/basis.py index 7613945db..eeda70c6e 100644 --- a/fda/basis.py +++ b/fda/basis.py @@ -2161,25 +2161,22 @@ def __radd__(self, other): def __sub__(self, other): """Subtraction for FDataBasis object.""" - if isinstance(other, FDataBasis): - return (self.__add__( - other.copy(coefficients=(-1 * self.coefficients)))) - else: - return self.__add__(-1 * other) + return self.__add__(-1 * other) def __rsub__(self, other): """Right subtraction for FDataBasis object.""" - return ((self.copy(coefficients=(-1 * self.coefficients))) - .__add__(other)) + return (-1 * self).__add__(other) def __mul__(self, other): """Multiplication for FDataBasis object.""" + if isinstance(other, list) or isinstance(other, FDataBasis): + raise NotImplementedError - raise NotImplementedError + else: + self.copy(coefficients=other * self.coefficients) def __rmul__(self, other): """Multiplication for FDataBasis object.""" - return self.__mul__(other) def __truediv__(self, other): From d85c04009790240e258adf243e2ec8fd3e834de1 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Mon, 18 Mar 2019 18:42:29 +0100 Subject: [PATCH 005/222] Test for FDataBasis operations and fixed name --- fda/basis.py | 12 ++++---- tests/test_basis.py | 72 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/fda/basis.py b/fda/basis.py index eeda70c6e..7e7e4320e 100644 --- a/fda/basis.py +++ b/fda/basis.py @@ -2137,10 +2137,10 @@ def __getitem__(self, key): else: return self.copy(coefficients=self.coefficients[key]) - def add_samples(self): + def plus_samples(self): if self.nsamples == 1: return self - return self[0] + (self[1:].add_samples()) + return self[0] + (self[1:].plus_samples()) def __add__(self, other): """Addition for FDataBasis object.""" @@ -2161,6 +2161,8 @@ def __radd__(self, other): def __sub__(self, other): """Subtraction for FDataBasis object.""" + if isinstance(other, list): + other = numpy.array(other) return self.__add__(-1 * other) def __rsub__(self, other): @@ -2169,11 +2171,11 @@ def __rsub__(self, other): def __mul__(self, other): """Multiplication for FDataBasis object.""" - if isinstance(other, list) or isinstance(other, FDataBasis): + if isinstance(other, FDataBasis): raise NotImplementedError - else: - self.copy(coefficients=other * self.coefficients) + mult = numpy.atleast_2d(other).reshape(-1, 1) + return self.copy(coefficients=self.coefficients * mult) def __rmul__(self, other): """Multiplication for FDataBasis object.""" diff --git a/tests/test_basis.py b/tests/test_basis.py index 62d81c267..0c93a2d11 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -215,6 +215,78 @@ def test_fdatabasis_times_fdatabasis_int(self): self.assertEqual(expec_basis, result.basis) np.testing.assert_array_almost_equal(expec_coefs, result.coefficients) + def test_fdatabasis__add__(self): + monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + + np.testing.assert_equal(monomial1 + monomial2, + FDataBasis(Monomial(nbasis=3), + [[2, 4, 6], [4, 6, 8]])) + np.testing.assert_equal(monomial2 + 1, + FDataBasis(Monomial(nbasis=3), + [[2, 2, 3], [4, 4, 5]])) + np.testing.assert_equal(1 + monomial2, + FDataBasis(Monomial(nbasis=3), + [[2, 2, 3], [4, 4, 5]])) + np.testing.assert_equal(monomial2 + [1, 2], + FDataBasis(Monomial(nbasis=3), + [[2, 2, 3], [5, 4, 5]])) + np.testing.assert_equal([1, 2] + monomial2, + FDataBasis(Monomial(nbasis=3), + [[2, 2, 3], [5, 4, 5]])) + + np.testing.assert_raises(NotImplementedError, monomial2.__add__, + FDataBasis(Fourier(nbasis=3), + [[2, 2, 3], [5, 4, 5]])) + + def test_fdatabasis__sub__(self): + monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + + np.testing.assert_equal(monomial1 - monomial2, + FDataBasis(Monomial(nbasis=3), + [[0, 0, 0], [-2, -2, -2]])) + np.testing.assert_equal(monomial2 - 1, + FDataBasis(Monomial(nbasis=3), + [[0, 2, 3], [2, 4, 5]])) + np.testing.assert_equal(1 - monomial2, + FDataBasis(Monomial(nbasis=3), + [[0, -2, -3], [-2, -4, -5]])) + np.testing.assert_equal(monomial2 - [1, 2], + FDataBasis(Monomial(nbasis=3), + [[0, 2, 3], [1, 4, 5]])) + np.testing.assert_equal([1, 2] - monomial2, + FDataBasis(Monomial(nbasis=3), + [[0, -2, -3], [-1, -4, -5]])) + + np.testing.assert_raises(NotImplementedError, monomial2.__sub__, + FDataBasis(Fourier(nbasis=3), + [[2, 2, 3], [5, 4, 5]])) + def test_fdatabasis__mul__(self): + monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + + np.testing.assert_equal(monomial1 * 2, + FDataBasis(Monomial(nbasis=3), + [[2, 4, 6], [6, 8, 10]])) + np.testing.assert_equal(3 * monomial2, + FDataBasis(Monomial(nbasis=3), + [[3, 6, 9], [18, 24, 30]])) + np.testing.assert_equal(3 * monomial2, + monomial2 * 3) + + np.testing.assert_equal(monomial2 * [1, 2], + FDataBasis(Monomial(nbasis=3), + [[1, 2, 3], [6, 8, 10]])) + np.testing.assert_equal([1, 2] * monomial2, + FDataBasis(Monomial(nbasis=3), + [[1, 2, 3], [6, 8, 10]])) + + np.testing.assert_raises(NotImplementedError, monomial2.__mul__, + FDataBasis(Fourier(nbasis=3), + [[2, 2, 3], [5, 4, 5]])) + np.testing.assert_raises(NotImplementedError, monomial2.__mul__, + monomial2) if __name__ == '__main__': print() From d6f710b7dc6f2af74ee31cd922763fa62adf8d52 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Sat, 23 Mar 2019 21:29:49 +0100 Subject: [PATCH 006/222] Little test fixed --- tests/test_basis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index 0c93a2d11..600e804bd 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -268,10 +268,10 @@ def test_fdatabasis__mul__(self): np.testing.assert_equal(monomial1 * 2, FDataBasis(Monomial(nbasis=3), - [[2, 4, 6], [6, 8, 10]])) + [[2, 4, 6]])) np.testing.assert_equal(3 * monomial2, FDataBasis(Monomial(nbasis=3), - [[3, 6, 9], [18, 24, 30]])) + [[3, 6, 9], [9, 12, 15]])) np.testing.assert_equal(3 * monomial2, monomial2 * 3) From 58f6385e5ce78d2b862aea29dc57b6583a800b9a Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Mon, 1 Apr 2019 19:17:59 +0200 Subject: [PATCH 007/222] Fixed name function and code --- fda/basis.py | 7 +++---- tests/test_basis.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/fda/basis.py b/fda/basis.py index 77f797afc..0883f36b0 100644 --- a/fda/basis.py +++ b/fda/basis.py @@ -2193,10 +2193,9 @@ def __getitem__(self, key): else: return self.copy(coefficients=self.coefficients[key]) - def plus_samples(self): - if self.nsamples == 1: - return self - return self[0] + (self[1:].plus_samples()) + def sum_samples(self): + """Sums all the samples on the object to a single sample object""" + return self.copy(coefficients=numpy.sum(self.coefficients, axis=0)) def __add__(self, other): """Addition for FDataBasis object.""" diff --git a/tests/test_basis.py b/tests/test_basis.py index 600e804bd..c1ac12d5c 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -215,6 +215,16 @@ def test_fdatabasis_times_fdatabasis_int(self): self.assertEqual(expec_basis, result.basis) np.testing.assert_array_almost_equal(expec_coefs, result.coefficients) + def test_fdatabasis_sum_samples(self): + monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5], [10, 0 , 1]]) + + np.testing.assert_equal(monomial1.sum_samples().coefficients, + [[1, 2, 3]]) + + np.testing.assert_equal(monomial2.sum_samples().coefficients, + [[14, 6, 9]]) + def test_fdatabasis__add__(self): monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) From f9419eb6a77a68c7c7ae44b6f4b17007adcf6eef Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Sat, 27 Apr 2019 17:25:38 +0200 Subject: [PATCH 008/222] Fixed numpy array catching + Catching numpy error creation to throw an error if it's needed --- skfda/basis.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/skfda/basis.py b/skfda/basis.py index 4ea5963c5..55172ed79 100644 --- a/skfda/basis.py +++ b/skfda/basis.py @@ -2208,7 +2208,10 @@ def __add__(self, other): self.coefficients + other.coefficients) coefs = self.coefficients.copy() - coefs[:, 0] = self.coefficients[:, 0] + numpy.array(other) + try: + coefs[:, 0] = self.coefficients[:, 0] + numpy.array(other) + except: + return NotImplementedError return FDataBasis(self.basis.copy(), coefs) def __radd__(self, other): From 3c4b968aaab6e690a53c54fb630495a2be281d77 Mon Sep 17 00:00:00 2001 From: Pablo Date: Tue, 30 Apr 2019 18:21:29 +0200 Subject: [PATCH 009/222] Update basis.py Except checked --- skfda/basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/basis.py b/skfda/basis.py index 55172ed79..740dba2b9 100644 --- a/skfda/basis.py +++ b/skfda/basis.py @@ -2210,7 +2210,7 @@ def __add__(self, other): coefs = self.coefficients.copy() try: coefs[:, 0] = self.coefficients[:, 0] + numpy.array(other) - except: + except TypeError: return NotImplementedError return FDataBasis(self.basis.copy(), coefs) From aee58dcc5e3ea382d7a3c1c837ced69ab30530db Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Mon, 6 May 2019 14:11:55 +0200 Subject: [PATCH 010/222] Exception handling --- skfda/basis.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skfda/basis.py b/skfda/basis.py index edae178a7..806b9185d 100644 --- a/skfda/basis.py +++ b/skfda/basis.py @@ -2196,8 +2196,9 @@ def __add__(self, other): self.coefficients + other.coefficients) coefs = self.coefficients.copy() + other = numpy.array(other) try: - coefs[:, 0] = self.coefficients[:, 0] + numpy.array(other) + coefs[:, 0] = self.coefficients[:, 0] + other except TypeError: return NotImplementedError return FDataBasis(self.basis.copy(), coefs) From 26451d3fce129e6730dc7e14d4e9393d2ee320a8 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Sat, 11 May 2019 10:54:55 +0200 Subject: [PATCH 011/222] Implemented operations on basis --- skfda/basis.py | 71 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 56 insertions(+), 15 deletions(-) diff --git a/skfda/basis.py b/skfda/basis.py index 806b9185d..b234b5d54 100644 --- a/skfda/basis.py +++ b/skfda/basis.py @@ -325,6 +325,42 @@ def _to_R(self): def inner_product(self, other): return numpy.transpose(other.inner_product(self.to_basis())) + def add_same_basis(self, coefs1, coefs2): + return self.copy(), coefs1 + coefs2 + + def add_costant(self, coefs, other): + coefs = coefs.copy() + other = numpy.array(other) + try: + coefs[:, 0] = coefs[:, 0] + other + except TypeError: + raise NotImplementedError + + return self.copy(), coefs + + def sub_same_basis(self, coefs1, coefs2): + return self.copy(), coefs1 - coefs2 + + def sub_costant(self, coefs, other): + coefs = coefs.copy() + other = numpy.array(other) + try: + coefs[:, 0] = coefs[:, 0] - other + except TypeError: + raise NotImplementedError + + return self.copy(), coefs + + def mul_costant(self, coefs, other): + coefs = coefs.copy() + other = numpy.atleast_2d(other).reshape(-1, 1) + try: + coefs = coefs * other + except TypeError: + raise NotImplementedError + + return self.copy(), coefs + def __repr__(self): """Representation of a Basis object.""" return (f"{self.__class__.__name__}(domain_range={self.domain_range}, " @@ -2192,38 +2228,43 @@ def __add__(self, other): if self.basis != other.basis: raise NotImplementedError else: - return FDataBasis(self.basis.copy(), - self.coefficients + other.coefficients) + basis, coefs = self.basis.add_same_basis(self.coefficients, + other.coefficients) + else: + basis, coefs = self.basis.add_costant(self.coefficients, other) - coefs = self.coefficients.copy() - other = numpy.array(other) - try: - coefs[:, 0] = self.coefficients[:, 0] + other - except TypeError: - return NotImplementedError - return FDataBasis(self.basis.copy(), coefs) + return self.copy(basis=basis, coefficients=coefs) def __radd__(self, other): """Addition for FDataBasis object.""" + return self.__add__(other) def __sub__(self, other): """Subtraction for FDataBasis object.""" - if isinstance(other, list): - other = numpy.array(other) - return self.__add__(-1 * other) + if isinstance(other, FDataBasis): + if self.basis != other.basis: + raise NotImplementedError + else: + basis, coefs = self.basis.sub_same_basis(self.coefficients, + other.coefficients) + else: + basis, coefs = self.basis.sub_costant(self.coefficients, other) + + return self.copy(basis=basis, coefficients=coefs) def __rsub__(self, other): """Right subtraction for FDataBasis object.""" - return (-1 * self).__add__(other) + return (self * -1).__add__(other) def __mul__(self, other): """Multiplication for FDataBasis object.""" if isinstance(other, FDataBasis): raise NotImplementedError - mult = numpy.atleast_2d(other).reshape(-1, 1) - return self.copy(coefficients=self.coefficients * mult) + basis, coefs = self.basis.mul_costant(self.coefficients, other) + + return self.copy(basis=basis, coefficients=coefs) def __rmul__(self, other): """Multiplication for FDataBasis object.""" From dd8ba7cdafada70f1640f8ad0a133eda605f0dcb Mon Sep 17 00:00:00 2001 From: pablomm Date: Fri, 17 May 2019 00:19:46 +0200 Subject: [PATCH 012/222] Operations with image dimensions --- skfda/representation/_functional_data.py | 37 ++++++++++- skfda/representation/basis.py | 56 ++++++++++++++-- skfda/representation/grid.py | 85 ++++++++++++++++++++++-- 3 files changed, 162 insertions(+), 16 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 5996d4766..ae93fd633 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -85,6 +85,34 @@ def ndim_codomain(self): """ return self.ndim_image + @abstractmethod + def image(self, dim=None): + r"""Return a component of the FDataGrid. + + If the functional object contains samples + :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns + a component of the vector :math:`f = (f_1, ..., f_d)`. + + If dim is not specified an iterator over the image dimensions it is + returned. + + """ + pass + + def codomain(self, dim=None): + r"""Return a component of the FDataGrid. Alias to :meth:`image`. + + If the functional object contains samples + :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns + a component of the vector :math:`f = (f_1, ..., f_d)`. + + If dim is not specified an iterator over the image dimensions it is + returned. + + """ + return self.image(dim=dim) + + @property def extrapolation(self): """Return default type of extrapolation.""" @@ -989,20 +1017,23 @@ def to_basis(self, basis, eval_points=None, **kwargs): pass @abstractmethod - def concatenate(self, other): + def concatenate(self, *others, image=False): """Join samples from a similar FData object. Joins samples from another FData object if it has the same dimensions and has compatible representations. Args: - other (:class:`FData`): another FData object. + others (:class:`FData`): other FData objects. + image (boolean, optional): If False concatenates as more samples, + else, concatenates the other functions as new componentes of the + image. Defaults to false. Returns: :class:`FData`: FData object with the samples from the two original objects. + """ - pass @abstractmethod diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 5f5c1ec42..eeb05a701 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1640,6 +1640,41 @@ def ndim_image(self): # Only image dimension equal to 1 is supported return 1 + def _image_iterator(self): + """Iterator over the image dimensions""" + yield self.copy() + + def image(self, dim=None): + r"""Return a component of the FDataBasis. + + If the functional object contains samples + :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns + a component of the vector :math:`f = (f_1, ..., f_d)`. + + If dim is not specified an iterator over the image dimensions it is + returned. + + Args: + dim (int, optional): Number of component of the image to be + returned, or None to iterate over all the components. + + Todo: + By the moment only unidimensional objects are supported in basis + form. + + """ + # Thats a dummie method to override the @abstractmethod + + if dim is None: + return self._image_iterator() + + elif dim != 1: + raise ValueError(f"Incorrect image dimension. Should be a " + f"number between 1 and {self.ndim_image}.") + else: + return self.copy() + + @property def nbasis(self): """Return number of basis.""" @@ -2130,26 +2165,33 @@ def __eq__(self, other): # TODO check all other params return self.basis == other.basis and numpy.all(self.coefficients == other.coefficients) - def concatenate(self, other): + def concatenate(self, *others, image=False): """Join samples from a similar FDataBasis object. Joins samples from another FDataBasis object if they have the same basis. Args: - other (:class:`FDataBasis`): another FDataBasis object. + others (:class:`FDataBasis`): other FDataBasis objects. + image (boolean, optional): If False concatenates as more samples, + else, concatenates the other functions as new componentes of the + image. Multidimensional objects are not supported in basis form. Returns: :class:`FDataBasis`: FDataBasis object with the samples from the two original objects. """ - if other.basis != self.basis: - raise ValueError("The objects should have the same basis.") + if image is True: + return NotImplemented + + for other in others: + if other.basis != self.basis: + raise ValueError("The objects should have the same basis.") + + data = [self.coefficients] + [other.coefficients for other in others] - return self.copy(coefficients=numpy.concatenate((self.coefficients, - other.coefficients), - axis=0)) + return self.copy(coefficients=numpy.concatenate(data, axis=0)) def compose(self, fd, *, eval_points=None, **kwargs): """Composition of functions. diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 9637ce52c..f5daaf1bd 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -227,6 +227,74 @@ def ndim_image(self): except IndexError: return 1 + def _image_iterator(self): + """Returns an iterator throught the image dimensions""" + for k in range(self.ndim_image): + yield self.copy(data_matrix=self.data_matrix[..., k]) + + def image(self, dim=None): + r"""Return a component of the FDataGrid. + + If the functional object contains samples + :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns + a component of the vector :math:`f = (f_1, ..., f_d)`. + + If dim is not specified an iterator over the image dimensions it is + returned. + + Args: + dim (int, optional): Number of component of the image to be + returned, or None to iterate over all the components. + + Examples: + + We will construct a dataset of curves in :math:`\mathbb{R}^3` + + >>> from skfda.datasets import make_multimodal_samples + >>> fd = make_multimodal_samples(ndim_image=3, random_state=0) + >>> fd.ndim_image + 3 + + The functions of this dataset are vectorial functions + :math:`f(t) = (f_1(t), f_2(t), f_3(t))`. We can obtain a specific + component of the vector, for example, the first one. + + >>> fd_1 = fd.image(1) + >>> fd_1 + FDataGrid(...) + + The function returned has image dimension equal to 1 + + >>> fd_1.ndim_image + 1 + + We can use this method to iterate throught all the samples. + + >>> for fd_i in fd.image(): + ... fd_1.ndim_image + 1 + 1 + 1 + + This method can be used to split the FDataGrid in a list with + their compontes. + + >>> fd_list = list(fd.image()) + >>> len(fd_list) + 3 + + """ + # Returns an iterator over the dimensions + if dim is None: + return self._image_iterator() + + else: + if dim < 1 or dim > self.ndim_image: + raise ValueError(f"Incorrect image dimension. Should be a " + f"number between 1 and {self.ndim_image}.") + + return self.copy(data_matrix=self.data_matrix[..., dim-1]) + @property def ndim(self): """Return number of dimensions of the data matrix. @@ -620,14 +688,17 @@ def __rtruediv__(self, other): return self.copy(data_matrix=data_matrix / self.data_matrix) - def concatenate(self, other): + def concatenate(self, *others, image=False): """Join samples from a similar FDataGrid object. Joins samples from another FDataGrid object if it has the same dimensions and sampling points. Args: - other (:obj:`FDataGrid`): another FDataGrid object. + others (:obj:`FDataGrid`): another FDataGrid object. + image (boolean, optional): If False concatenates as more samples, + else, concatenates the other functions as new componentes of the + image. Defaults to false. Returns: :obj:`FDataGrid`: FDataGrid object with the samples from the two @@ -655,11 +726,13 @@ def concatenate(self, other): """ # Checks - self.__check_same_dimensions(other) + for other in others: + self.__check_same_dimensions(other) + + data = [self.data_matrix] + [other.data_matrix for other in others] + axis = 0 if image is False else -1 - return self.copy(data_matrix=numpy.concatenate((self.data_matrix, - other.data_matrix), - axis=0)) + return self.copy(data_matrix=numpy.concatenate(data, axis=axis)) def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): From a1abfe83e8ca181ca052b01c979d39bd429d6e4d Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 17 May 2019 17:36:07 +0200 Subject: [PATCH 013/222] Added the hability of div by a constant and some fixes --- skfda/representation/basis.py | 25 ++++++++++++++++--------- tests/test_basis.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 33adb2340..ea322f6bb 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -324,14 +324,14 @@ def _to_R(self): def inner_product(self, other): return numpy.transpose(other.inner_product(self.to_basis())) - def add_same_basis(self, coefs1, coefs2): + def _add_same_basis(self, coefs1, coefs2): return self.copy(), coefs1 + coefs2 - def add_costant(self, coefs, other): + def _add_costant(self, coefs, constant): coefs = coefs.copy() - other = numpy.array(other) + constant = numpy.array(constant) try: - coefs[:, 0] = coefs[:, 0] + other + coefs[:, 0] = coefs[:, 0] + constant except TypeError: raise NotImplementedError @@ -350,7 +350,7 @@ def sub_costant(self, coefs, other): return self.copy(), coefs - def mul_costant(self, coefs, other): + def _mul_costant(self, coefs, other): coefs = coefs.copy() other = numpy.atleast_2d(other).reshape(-1, 1) try: @@ -2230,10 +2230,10 @@ def __add__(self, other): if self.basis != other.basis: raise NotImplementedError else: - basis, coefs = self.basis.add_same_basis(self.coefficients, + basis, coefs = self.basis._add_same_basis(self.coefficients, other.coefficients) else: - basis, coefs = self.basis.add_costant(self.coefficients, other) + basis, coefs = self.basis._add_costant(self.coefficients, other) return self.copy(basis=basis, coefficients=coefs) @@ -2264,7 +2264,7 @@ def __mul__(self, other): if isinstance(other, FDataBasis): raise NotImplementedError - basis, coefs = self.basis.mul_costant(self.coefficients, other) + basis, coefs = self.basis._mul_costant(self.coefficients, other) return self.copy(basis=basis, coefficients=coefs) @@ -2275,7 +2275,14 @@ def __rmul__(self, other): def __truediv__(self, other): """Division for FDataBasis object.""" - raise NotImplementedError + other = numpy.array(other) + + try: + other = 1 / other + except TypeError: + raise NotImplementedError + + return self * other def __rtruediv__(self, other): """Right division for FDataBasis object.""" diff --git a/tests/test_basis.py b/tests/test_basis.py index 3df6e9844..7f2d68bec 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -300,6 +300,28 @@ def test_fdatabasis__mul__(self): np.testing.assert_raises(NotImplementedError, monomial2.__mul__, monomial2) + + def test_fdatabasis__mul__(self): + monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + + np.testing.assert_equal(monomial1 / 2, + FDataBasis(Monomial(nbasis=3), + [[1/2, 1, 3/2]])) + np.testing.assert_equal(monomial2 / 2, + FDataBasis(Monomial(nbasis=3), + [[1/2, 1, 3/2], [3/2, 2, 5/2]])) + + np.testing.assert_equal(monomial2 / [1, 2], + FDataBasis(Monomial(nbasis=3), + [[1, 2, 3], [3/2, 2, 5/2]])) + + # np.testing.assert_raises(NotImplementedError, monomial2.__mul__, + # FDataBasis(Fourier(nbasis=3), + # [[2, 2, 3], [5, 4, 5]])) + # np.testing.assert_raises(NotImplementedError, monomial2.__mul__, + # monomial2) + if __name__ == '__main__': print() unittest.main() From caf3a06563f1fb20210851bbb0e590ba7c5748ce Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 18 May 2019 17:10:59 +0200 Subject: [PATCH 014/222] Coordinate iterator --- skfda/representation/_functional_data.py | 31 ++---- skfda/representation/basis.py | 63 +++++++------ skfda/representation/grid.py | 114 +++++++++++++---------- 3 files changed, 110 insertions(+), 98 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index ae93fd633..a8c0358a0 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -41,6 +41,7 @@ def __init__(self, extrapolation, dataset_label, axes_labels, keepdims): self.dataset_label = dataset_label self.axes_labels = axes_labels self.keepdims = keepdims + self._coordinate = None @property @abstractmethod @@ -85,34 +86,18 @@ def ndim_codomain(self): """ return self.ndim_image + @property @abstractmethod - def image(self, dim=None): + def coordinate(self): r"""Return a component of the FDataGrid. If the functional object contains samples :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns a component of the vector :math:`f = (f_1, ..., f_d)`. - If dim is not specified an iterator over the image dimensions it is - returned. - """ pass - def codomain(self, dim=None): - r"""Return a component of the FDataGrid. Alias to :meth:`image`. - - If the functional object contains samples - :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns - a component of the vector :math:`f = (f_1, ..., f_d)`. - - If dim is not specified an iterator over the image dimensions it is - returned. - - """ - return self.image(dim=dim) - - @property def extrapolation(self): """Return default type of extrapolation.""" @@ -1017,7 +1002,7 @@ def to_basis(self, basis, eval_points=None, **kwargs): pass @abstractmethod - def concatenate(self, *others, image=False): + def concatenate(self, *others, as_coordinate=False): """Join samples from a similar FData object. Joins samples from another FData object if it has the same @@ -1025,14 +1010,14 @@ def concatenate(self, *others, image=False): Args: others (:class:`FData`): other FData objects. - image (boolean, optional): If False concatenates as more samples, - else, concatenates the other functions as new componentes of the - image. Defaults to false. + as_coordinate (boolean, optional): If False concatenates as + new samples, else, concatenates the other functions as + new componentes of the image. Defaults to false. Returns: :class:`FData`: FData object with the samples from the two original objects. - + """ pass diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index eeb05a701..ff907125f 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1379,6 +1379,31 @@ class FDataBasis(FData): ...) """ + class _CoordinateIterator: + """Internal class to iterate through the image coordinates. + + Dummy object. Should be change to support multidimensional objects. + + """ + def __init__(self, fdatabasis): + """Create an iterator through the image coordinates.""" + self._fdatabasis = fdatabasis + + def __iter__(self): + """Return an iterator through the image coordinates.""" + yield self._fdatabasis.copy() + + def __getitem__(self, key): + """Get a specific coordinate.""" + + if key != 0: + return NotImplemented + + return self._fdatabasis.copy() + + def __len__(self): + """Return the number of coordinates.""" + return self._fdatabasis.ndim_image def __init__(self, basis, coefficients, *, dataset_label=None, axes_labels=None, extrapolation=None, keepdims=False): @@ -1640,40 +1665,24 @@ def ndim_image(self): # Only image dimension equal to 1 is supported return 1 - def _image_iterator(self): - """Iterator over the image dimensions""" - yield self.copy() - - def image(self, dim=None): + @property + def coordinate(self,): r"""Return a component of the FDataBasis. If the functional object contains samples - :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns + :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this object allows a component of the vector :math:`f = (f_1, ..., f_d)`. - If dim is not specified an iterator over the image dimensions it is - returned. - - Args: - dim (int, optional): Number of component of the image to be - returned, or None to iterate over all the components. Todo: By the moment only unidimensional objects are supported in basis form. """ - # Thats a dummie method to override the @abstractmethod - - if dim is None: - return self._image_iterator() - - elif dim != 1: - raise ValueError(f"Incorrect image dimension. Should be a " - f"number between 1 and {self.ndim_image}.") - else: - return self.copy() + if self._coordinate is None: + self._coordinate = FDataBasis._CoordinateIterator(self) + return self._coordinate @property def nbasis(self): @@ -2165,7 +2174,7 @@ def __eq__(self, other): # TODO check all other params return self.basis == other.basis and numpy.all(self.coefficients == other.coefficients) - def concatenate(self, *others, image=False): + def concatenate(self, *others, as_coordinate=False): """Join samples from a similar FDataBasis object. Joins samples from another FDataBasis object if they have the same @@ -2173,16 +2182,16 @@ def concatenate(self, *others, image=False): Args: others (:class:`FDataBasis`): other FDataBasis objects. - image (boolean, optional): If False concatenates as more samples, - else, concatenates the other functions as new componentes of the - image. Multidimensional objects are not supported in basis form. + as_coordinate (boolean, optional): If False concatenates as + new samples, else, concatenates the other functions as + new componentes of the image. Defaults to false. Returns: :class:`FDataBasis`: FDataBasis object with the samples from the two original objects. """ - if image is True: + if as_coordinate: return NotImplemented for other in others: diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index f5daaf1bd..8ec07b08d 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -96,11 +96,32 @@ class FDataGrid(FData): """ + class _CoordinateIterator: + """Internal class to iterate through the image coordinates.""" + + def __init__(self, fdatagrid): + """Create an iterator through the image coordinates.""" + self._fdatagrid = fdatagrid + + def __iter__(self): + """Return an iterator through the image coordinates.""" + for k in range(len(self)): + yield self._fdatagrid.copy( + data_matrix=self._fdatagrid.data_matrix[..., k]) + + def __getitem__(self, key): + """Get a specific coordinate.""" + return self._fdatagrid.copy( + data_matrix=self._fdatagrid.data_matrix[..., key]) + + def __len__(self): + """Return the number of coordinates.""" + return self._fdatagrid.ndim_image + def __init__(self, data_matrix, sample_points=None, domain_range=None, dataset_label=None, axes_labels=None, extrapolation=None, interpolator=None, keepdims=False): - """Construct a FDataGrid object. Args: @@ -108,8 +129,8 @@ def __init__(self, data_matrix, sample_points=None, values of a functional datum evaluated at the points of discretisation. sample_points (array_like, optional): an array containing the - points of discretisation where values have been recorded or a list - of lists with each of the list containing the points of + points of discretisation where values have been recorded or a + list of lists with each of the list containing the points of dicretisation for each axis. domain_range (tuple or list of tuples, optional): contains the edges of the interval in which the functional data is @@ -118,9 +139,9 @@ def __init__(self, data_matrix, sample_points=None, the domain). dataset_label (str, optional): name of the dataset. axes_labels (list, optional): list containing the labels of the - different axes. The length of the list must be equal to the sum of the - number of dimensions of the domain plus the number of dimensions - of the image. + different axes. The length of the list must be equal to the sum + of the number of dimensions of the domain plus the number of + dimensions of the image. """ self.data_matrix = numpy.atleast_2d(data_matrix) @@ -227,24 +248,14 @@ def ndim_image(self): except IndexError: return 1 - def _image_iterator(self): - """Returns an iterator throught the image dimensions""" - for k in range(self.ndim_image): - yield self.copy(data_matrix=self.data_matrix[..., k]) - - def image(self, dim=None): - r"""Return a component of the FDataGrid. - - If the functional object contains samples - :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns - a component of the vector :math:`f = (f_1, ..., f_d)`. - - If dim is not specified an iterator over the image dimensions it is - returned. + @property + def coordinate(self): + r"""Returns an object to access to the image coordinates. - Args: - dim (int, optional): Number of component of the image to be - returned, or None to iterate over all the components. + If the functional object contains multivariate samples + :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this class allows + iterate and get coordinates of the vector + :math:`f = (f_0, ..., f_{d-1})`. Examples: @@ -256,44 +267,45 @@ def image(self, dim=None): 3 The functions of this dataset are vectorial functions - :math:`f(t) = (f_1(t), f_2(t), f_3(t))`. We can obtain a specific + :math:`f(t) = (f_0(t), f_1(t), f_2(t))`. We can obtain a specific component of the vector, for example, the first one. - >>> fd_1 = fd.image(1) - >>> fd_1 + >>> fd_0 = fd.coordinate[0] + >>> fd_0 FDataGrid(...) - The function returned has image dimension equal to 1 + The object returned has image dimension equal to 1 - >>> fd_1.ndim_image + >>> fd_0.ndim_image 1 - We can use this method to iterate throught all the samples. + Or we can get multiple components, it can be accesed as a 1-d + numpy array of coordinates, for example, :math:`(f_0(t), f_1(t))`. + + >>> fd_01 = fd.coordinate[0:2] + >>> fd_01.ndim_image + 2 - >>> for fd_i in fd.image(): - ... fd_1.ndim_image + We can use this method to iterate throught all the coordinates. + + >>> for fd_i in fd.coordinate: + ... fd_i.ndim_image 1 1 1 - This method can be used to split the FDataGrid in a list with - their compontes. + This object can be used to split a FDataGrid in a list with + their components. - >>> fd_list = list(fd.image()) + >>> fd_list = list(fd.coordinate) >>> len(fd_list) 3 """ - # Returns an iterator over the dimensions - if dim is None: - return self._image_iterator() - - else: - if dim < 1 or dim > self.ndim_image: - raise ValueError(f"Incorrect image dimension. Should be a " - f"number between 1 and {self.ndim_image}.") + if self._coordinate is None: + self._coordinate = FDataGrid._CoordinateIterator(self) - return self.copy(data_matrix=self.data_matrix[..., dim-1]) + return self._coordinate @property def ndim(self): @@ -688,7 +700,7 @@ def __rtruediv__(self, other): return self.copy(data_matrix=data_matrix / self.data_matrix) - def concatenate(self, *others, image=False): + def concatenate(self, *others, as_coordinate=False): """Join samples from a similar FDataGrid object. Joins samples from another FDataGrid object if it has the same @@ -696,9 +708,9 @@ def concatenate(self, *others, image=False): Args: others (:obj:`FDataGrid`): another FDataGrid object. - image (boolean, optional): If False concatenates as more samples, - else, concatenates the other functions as new componentes of the - image. Defaults to false. + as_coordinate (boolean, optional): If False concatenates as + new samples, else, concatenates the other functions as + new componentes of the image. Defaults to false. Returns: :obj:`FDataGrid`: FDataGrid object with the samples from the two @@ -729,8 +741,14 @@ def concatenate(self, *others, image=False): for other in others: self.__check_same_dimensions(other) + if as_coordinate: + if any([self.nsamples != other.nsamples for other in others]): + raise ValueError(f"All the FDataGrids must contain the same " + f"number of samples {self.nsamples} to " + f"concatenate as a new coordinate.") + data = [self.data_matrix] + [other.data_matrix for other in others] - axis = 0 if image is False else -1 + axis = 0 if as_coordinate is False else -1 return self.copy(data_matrix=numpy.concatenate(data, axis=axis)) From ed54b0df5f0be32e228daa78207a5d57fe5c1ee0 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Sat, 18 May 2019 18:13:17 +0200 Subject: [PATCH 015/222] Update skfda/representation/_functional_data.py Typo in doc --- skfda/representation/_functional_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index a8c0358a0..290137e15 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -1012,7 +1012,7 @@ def concatenate(self, *others, as_coordinate=False): others (:class:`FData`): other FData objects. as_coordinate (boolean, optional): If False concatenates as new samples, else, concatenates the other functions as - new componentes of the image. Defaults to false. + new componentes of the image. Defaults to False. Returns: :class:`FData`: FData object with the samples from the two From 5f7f4d18e6ef21e6cec148a95f567b1f718feabe Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Sat, 18 May 2019 18:13:28 +0200 Subject: [PATCH 016/222] Update skfda/representation/basis.py Typo in doc --- skfda/representation/basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 4d8d70c9b..4bfa27285 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -2259,7 +2259,7 @@ def concatenate(self, *others, as_coordinate=False): others (:class:`FDataBasis`): other FDataBasis objects. as_coordinate (boolean, optional): If False concatenates as new samples, else, concatenates the other functions as - new componentes of the image. Defaults to false. + new componentes of the image. Defaults to False. Returns: :class:`FDataBasis`: FDataBasis object with the samples from the two From a9808794b195357e2485eca986d3df9f6edb0522 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 19 May 2019 20:54:16 +0200 Subject: [PATCH 017/222] Typo fix --- skfda/representation/basis.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 5dd18bbda..26f9b1476 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -332,7 +332,7 @@ def inner_product(self, other): def _add_same_basis(self, coefs1, coefs2): return self.copy(), coefs1 + coefs2 - def _add_costant(self, coefs, constant): + def _add_constant(self, coefs, constant): coefs = coefs.copy() constant = numpy.array(constant) try: @@ -345,7 +345,7 @@ def _add_costant(self, coefs, constant): def sub_same_basis(self, coefs1, coefs2): return self.copy(), coefs1 - coefs2 - def sub_costant(self, coefs, other): + def sub_constant(self, coefs, other): coefs = coefs.copy() other = numpy.array(other) try: @@ -355,7 +355,7 @@ def sub_costant(self, coefs, other): return self.copy(), coefs - def _mul_costant(self, coefs, other): + def _mul_constant(self, coefs, other): coefs = coefs.copy() other = numpy.atleast_2d(other).reshape(-1, 1) try: @@ -2308,7 +2308,7 @@ def __add__(self, other): basis, coefs = self.basis._add_same_basis(self.coefficients, other.coefficients) else: - basis, coefs = self.basis._add_costant(self.coefficients, other) + basis, coefs = self.basis._add_constant(self.coefficients, other) return self.copy(basis=basis, coefficients=coefs) @@ -2326,7 +2326,7 @@ def __sub__(self, other): basis, coefs = self.basis.sub_same_basis(self.coefficients, other.coefficients) else: - basis, coefs = self.basis.sub_costant(self.coefficients, other) + basis, coefs = self.basis.sub_constant(self.coefficients, other) return self.copy(basis=basis, coefficients=coefs) @@ -2339,7 +2339,7 @@ def __mul__(self, other): if isinstance(other, FDataBasis): raise NotImplementedError - basis, coefs = self.basis._mul_costant(self.coefficients, other) + basis, coefs = self.basis._mul_constant(self.coefficients, other) return self.copy(basis=basis, coefficients=coefs) From 8f1918c7cbc1a5adfeea60aea00e211b50f45242 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 19 May 2019 20:57:51 +0200 Subject: [PATCH 018/222] Some more fixes --- skfda/representation/basis.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 26f9b1476..354ba0fc7 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -338,7 +338,7 @@ def _add_constant(self, coefs, constant): try: coefs[:, 0] = coefs[:, 0] + constant except TypeError: - raise NotImplementedError + return NotImplemented return self.copy(), coefs @@ -351,7 +351,7 @@ def sub_constant(self, coefs, other): try: coefs[:, 0] = coefs[:, 0] - other except TypeError: - raise NotImplementedError + return NotImplemented return self.copy(), coefs @@ -361,7 +361,7 @@ def _mul_constant(self, coefs, other): try: coefs = coefs * other except TypeError: - raise NotImplementedError + return NotImplemented return self.copy(), coefs @@ -2355,7 +2355,7 @@ def __truediv__(self, other): try: other = 1 / other except TypeError: - raise NotImplementedError + return NotImplemented return self * other From 07732f40e21ddeb68e2f5ebe4bb2426fc9b59cb9 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 19 May 2019 21:20:17 +0200 Subject: [PATCH 019/222] Fixed the return NotImplemented --- skfda/representation/basis.py | 36 +++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 354ba0fc7..6161026fa 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -335,33 +335,24 @@ def _add_same_basis(self, coefs1, coefs2): def _add_constant(self, coefs, constant): coefs = coefs.copy() constant = numpy.array(constant) - try: - coefs[:, 0] = coefs[:, 0] + constant - except TypeError: - return NotImplemented + coefs[:, 0] = coefs[:, 0] + constant return self.copy(), coefs - def sub_same_basis(self, coefs1, coefs2): + def _sub_same_basis(self, coefs1, coefs2): return self.copy(), coefs1 - coefs2 - def sub_constant(self, coefs, other): + def _sub_constant(self, coefs, other): coefs = coefs.copy() other = numpy.array(other) - try: - coefs[:, 0] = coefs[:, 0] - other - except TypeError: - return NotImplemented + coefs[:, 0] = coefs[:, 0] - other return self.copy(), coefs def _mul_constant(self, coefs, other): coefs = coefs.copy() other = numpy.atleast_2d(other).reshape(-1, 1) - try: - coefs = coefs * other - except TypeError: - return NotImplemented + coefs = coefs * other return self.copy(), coefs @@ -2308,7 +2299,10 @@ def __add__(self, other): basis, coefs = self.basis._add_same_basis(self.coefficients, other.coefficients) else: - basis, coefs = self.basis._add_constant(self.coefficients, other) + try: + basis, coefs = self.basis._add_constant(self.coefficients, other) + except TypeError: + return NotImplemented return self.copy(basis=basis, coefficients=coefs) @@ -2323,10 +2317,13 @@ def __sub__(self, other): if self.basis != other.basis: raise NotImplementedError else: - basis, coefs = self.basis.sub_same_basis(self.coefficients, + basis, coefs = self.basis._sub_same_basis(self.coefficients, other.coefficients) else: - basis, coefs = self.basis.sub_constant(self.coefficients, other) + try: + basis, coefs = self.basis._sub_constant(self.coefficients, other) + except TypeError: + return NotImplemented return self.copy(basis=basis, coefficients=coefs) @@ -2339,7 +2336,10 @@ def __mul__(self, other): if isinstance(other, FDataBasis): raise NotImplementedError - basis, coefs = self.basis._mul_constant(self.coefficients, other) + try: + basis, coefs = self.basis._mul_constant(self.coefficients, other) + except TypeError: + return NotImplemented return self.copy(basis=basis, coefficients=coefs) From c8a7cfba57906acfa2bc8fbada094e786a2c96b3 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 19 May 2019 21:43:44 +0200 Subject: [PATCH 020/222] No longer necessary sum_samples function --- skfda/representation/basis.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 6161026fa..654829c27 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -2286,10 +2286,6 @@ def __getitem__(self, key): else: return self.copy(coefficients=self.coefficients[key]) - def sum_samples(self): - """Sums all the samples on the object to a single sample object""" - return self.copy(coefficients=numpy.sum(self.coefficients, axis=0)) - def __add__(self, other): """Addition for FDataBasis object.""" if isinstance(other, FDataBasis): From e33fe1a1d1d97af32ad5160eb5297569275c4e52 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 19 May 2019 21:50:16 +0200 Subject: [PATCH 021/222] Removing no necessary test --- tests/test_basis.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index d54d81852..d271aa5b6 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -216,16 +216,6 @@ def test_fdatabasis_times_fdatabasis_int(self): self.assertEqual(expec_basis, result.basis) np.testing.assert_array_almost_equal(expec_coefs, result.coefficients) - - def test_fdatabasis_sum_samples(self): - monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) - monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5], [10, 0 , 1]]) - - np.testing.assert_equal(monomial1.sum_samples().coefficients, - [[1, 2, 3]]) - - np.testing.assert_equal(monomial2.sum_samples().coefficients, - [[14, 6, 9]]) def test_fdatabasis__add__(self): monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) From 320dfbb7ac7e7fc2d653ecec12b9e7d19ad3d017 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 19 May 2019 23:36:53 +0200 Subject: [PATCH 022/222] Some changes to addapt to skfda --- .../ml/regression/scalar.py | 3 +- tests/test_regression.py | 45 ------------------- 2 files changed, 2 insertions(+), 46 deletions(-) rename fda/regression.py => skfda/ml/regression/scalar.py (98%) diff --git a/fda/regression.py b/skfda/ml/regression/scalar.py similarity index 98% rename from fda/regression.py rename to skfda/ml/regression/scalar.py index cec304e1c..9fab8a435 100644 --- a/fda/regression.py +++ b/skfda/ml/regression/scalar.py @@ -1,6 +1,7 @@ from sklearn.metrics import mean_squared_error -from fda.basis import * +from skfda.representation.basis import * +import numpy as np class ScalarRegression: diff --git a/tests/test_regression.py b/tests/test_regression.py index 8b45287a6..774354759 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,50 +1,5 @@ import unittest -from numpy import testing -import numpy as np -from fda.basis import * -from fda.regression import ScalarRegression -class TestRegression(unittest.TestCase): - - def test_Bspline(self): - - y = [3.170496, 3.162863, 3.168615, 3.101231, 3.079796, 3.052271, 2.903741, 2.952599, 2.969136, 3.082283, 3.045049, 2.973497, 2.960328, 2.893540, 2.981366, 2.847758, 2.706888, 2.652343, 2.611511, 2.569491, 2.609167, 2.559548, 2.667640, 2.602603, 2.434409, 3.062620, 2.930236, 2.784546, 3.413601, 2.434090, 2.515476, 2.428297, 2.617525, 2.415140, 2.158362] - - x0 = FDataBasis(Monomial(domain_range=(0, 365), nbasis=1), np.ones((35,1))) - x1basis = BSpline(domain_range=(0, 365), nbasis=65) - x1coefficients = np.array([[-3.47226935,-4.12302707,-3.4467276,-1.21303734,-5.5114315,-7.6717445,-23.1579052,-13.72184364,-14.9847397,-10.8490052,-10.3883037,-9.05390689,-10.1600572,-6.1934057,-5.5526683,-14.8632838,-18.3053246,-20.7676178,-24.170177,-16.524865191,-20.4535552,-23.15172642,-12.8030614,-8.8914016,-4.60730281,2.4775834,2.962431,-10.9451350,0.7863958,-16.7560345,-27.7366801,-23.2085112,-23.3407679,-25.0389689,-29.65729900], [-3.26637916,-4.58297790,-4.1460407,-1.94262937,-6.1899539,-7.9214086,-21.4158723,-14.95075455,-13.8216701,-11.4012695,-9.8901084,-9.12867690,-8.7346906,-3.5561742,-4.7569655,-11.9478586,-15.8889673,-18.6101565,-26.121923,-12.920320821,-15.1749575,-23.63415049,-11.9708848,-7.0124320,-6.71458251,1.8747273,2.890585,-9.7818307,-0.1281857,-19.7728598,-29.5975420,-27.3694586,-23.5475149,-29.8661207,-32.81546339], [-4.24946460,-7.00508561,-5.6373244,-3.29910816,-8.5584609,-10.8857096,-25.3380862,-15.63839741,-17.4009612,-12.9492214,-12.8163637,-10.60280648,-11.5369087,-8.1825921,-7.2810355,-16.7402119,-20.9800973,-24.9175460,-28.665881,-21.886014236,-24.8321738,-28.97225948,-18.8631975,-16.2648935,-7.87884967,1.2644897,1.941020,-16.3765301,-1.3361429,-20.0834559,-28.3686633,-27.7374558,-27.2815092,-24.6042960,-29.45089474], [-4.95772553,-6.07398338,-5.2195454,-4.00450164,-8.2656450,-10.3423696,-24.6437588,-16.42584865,-16.5522664,-13.6762064,-13.3305344,-11.78776933,-12.4891636,-7.0556666,-6.9179506,-16.0357555,-19.3199597,-22.4525202,-26.317398,-17.066930955,-21.3979173,-28.61933678,-15.9191093,-9.5667938,-4.60705787,3.7438430,4.486262,-9.1304372,1.0799893,-24.6332599,-35.2432669,-29.7497450,-26.3289594,-31.7777296,-33.41553550], [-5.45300180,-8.64207412,-8.0099103,-4.88439500,-10.7539539,-12.7502303,-24.8207517,-18.40409549,-19.4864434,-15.2718882,-14.2787813,-12.93434934,-13.1700724,-9.1324551,-8.9969335,-14.8702058,-17.2057903,-19.2180708,-26.591616,-14.432310623,-17.1211056,-24.06095020,-11.0250744,-6.5606475,-2.66365726,3.7762883,4.064820,-8.0818702,2.5088120,-11.2226698,-22.7604673,-25.8097187,-26.3630554,-29.9524257,-33.02321679], [-4.96060269,-3.95276264,-4.8061315,-1.34987914,-6.3154879,-7.3264702,-22.0749439,-10.79606676,-13.1265425,-10.0385965,-8.3946144,-7.35255718,-7.9404309,-4.2433152,-4.2548323,-13.9698057,-16.8047293,-20.5589898,-26.272942,-14.951273919,-18.4758872,-27.31552604,-11.9018223,-7.1607901,-4.42403762,3.1359431,3.571928,-6.8960090,1.5993438,-18.6360932,-27.5991004,-27.4217339,-26.2509191,-25.5103166,-32.58409920], [-4.10661249,-6.40093221,-6.0379306,-3.51537927,-8.0833744,-9.8749056,-22.8301598,-16.23868656,-16.2649420,-13.2665137,-11.3573036,-11.03773345,-11.9677521,-7.7044819,-7.9973408,-16.1786728,-20.1995362,-23.1711887,-30.335833,-18.004403329,-21.3354820,-30.42556959,-16.2813159,-11.9335541,-4.21911349,3.2897748,3.597416,-11.1712902,0.3420531,-18.5603014,-31.1103631,-31.9458708,-27.4551574,-31.5932330,-33.66106317], [-6.78081992,-8.22213132,-8.5686481,-5.22857223,-10.3992879,-10.7891413,-25.0064402,-15.99626101,-16.5274677,-12.8928170,-13.2490191,-11.40383933,-11.6354531,-7.3320490,-7.3085632,-15.0969846,-16.4316208,-17.5514265,-25.094357,-14.832458974,-16.9018755,-22.76344059,-11.0211189,-5.6202604,-2.73834865,3.8870117,3.995773,-5.1882596,2.9367292,-14.2146189,-25.6885556,-23.7263163,-29.3209250,-28.8730092,-31.73974280], [-4.11901824,-6.16460882,-6.6446683,-3.93735299,-7.9534113,-9.4649555,-21.4398476,-14.56682731,-16.0191517,-12.7490120,-11.9757935,-10.32540845,-10.8292310,-8.6593086,-8.9201365,-12.9457614,-15.8454188,-18.1618264,-26.096698,-12.286633354,-15.5483089,-22.57454221,-10.0865452,-5.4887701,-0.67296693,4.8356040,5.532489,-6.2704778,2.1892239,-12.4113620,-21.9202531,-25.5805716,-24.7134559,-26.6428676,-33.52987507], [-6.18302923,-6.52973154,-7.0279687,-3.47663131,-8.6677619,-8.4482807,-23.1105768,-12.54026163,-13.7297763,-11.1089913,-10.2985547,-8.40961878,-8.7840217,-4.3513495,-4.4705097,-12.1913053,-14.3281825,-18.4157505,-26.553746,-14.095980559,-17.9934149,-25.06402992,-13.7602557,-9.9547269,-0.30885353,5.0014333,4.814417,-6.4700207,2.4020769,-16.6353064,-27.6533078,-27.7655923,-29.1733476,-34.7591168,-32.80978902], [-5.54373766,-4.20947481,-5.7354933,-1.65537288,-6.6938955,-6.9006625,-21.4968306,-10.65833078,-11.4645986,-8.2846020,-7.0049443,-6.08811099,-6.4032607,-4.0788882,-4.0360774,-10.6623901,-13.6720927,-15.3018646,-24.177565,-9.987321912,-12.7824591,-17.33345189,-6.5738572,-1.4622431,1.79318977,5.3257559,5.422894,-1.7319738,3.8239880,-9.1135899,-20.8700500,-21.8394944,-26.1666745,-24.3491470,-33.49927366], [-4.11318768,-5.82550712,-6.2429585,-3.22509804,-7.6127002,-7.5523824,-19.9812265,-10.90933415,-12.9768809,-10.6573174,-10.9780681,-8.54653882,-8.4955718,-5.3643886,-5.5078950,-10.0514994,-10.5320262,-13.8711204,-23.320531,-10.274441524,-12.9214334,-19.08719231,-11.3215348,-8.3229045,0.29876481,5.0853126,4.741263,-6.5964436,1.7358117,-10.4659121,-18.0105529,-19.8489142,-27.6870568,-23.9345876,-33.04222836], [-3.24894386,-2.42058546,-3.7690475,0.03483671,-4.6547764,-3.9904840,-18.3228818,-8.40295958,-8.2922685,-5.2416361,-3.9681146,-3.14952929,-3.5657160,-1.1231098,-0.8334989,-7.6099012,-11.2040219,-12.8277280,-20.981423,-9.455724699,-12.6104695,-18.64318547,-6.4311627,-2.7987663,2.82763863,5.3941577,5.608377,-0.4690863,3.3928046,-11.5899503,-18.9627465,-21.8775098,-24.7752045,-27.0480882,-33.37725903], [-4.64848380,-3.37649958,-4.6122122,-1.63225946,-5.2123227,-4.6822143,-17.6171235,-8.23039864,-8.8295168,-7.4209192,-6.8105057,-4.75328510,-5.1784239,-2.9170251,-2.5124542,-7.1670509,-7.0980237,-10.3974088,-23.248764,-5.575179385,-7.9591323,-15.05774953,-6.7441654,-2.8305539,4.55693319,6.5461787,6.102359,-1.5570469,3.7787339,-4.6788625,-11.3910454,-18.9953345,-26.7285763,-22.9045131,-32.89433818], [-1.70996483,-1.55369455,-2.8430000,0.57406499,-3.0444056,-2.1687876,-16.7925301,-5.33104850,-6.9068912,-4.0200163,-3.6580861,-2.48378561,-2.5366321,-0.3729707,-0.1295860,-4.3782188,-6.5464526,-8.8081666,-18.618307,-4.974302749,-6.9960757,-15.10597215,-3.0701293,-0.4902792,5.53840342,6.9241089,6.833905,0.8558513,3.9778086,-8.0748984,-15.3493139,-17.9673809,-22.3907074,-25.3031244,-30.83367712], [-1.79468731,-0.02003312,-1.2256413,1.40376599,-1.5510013,-0.3946706,-13.1466151,-2.95408687,-3.0471534,-2.4239239,-2.2815159,-0.71713352,-1.1193723,-0.2266671,-0.5229033,-5.0306610,-3.5865254,-7.9279473,-20.092434,-3.219379207,-6.6705790,-14.56729480,-4.7207831,-2.4573009,5.36685741,6.5222566,6.347133,-0.3235591,3.8771446,-5.1022140,-7.8665892,-17.3578142,-24.2554792,-22.1577294,-30.01349068], [0.07407518,0.85332665,-0.2894852,2.04424906,-0.3461414,1.4361722,-10.3639334,-0.71965175,-1.9236248,-0.7737661,0.3916233,2.30763691,1.9505468,3.8639162,4.4419782,-1.1870637,-3.6636184,-6.2289254,-15.190057,-2.590296542,-4.6701189,-8.88482859,-0.5149147,1.0918139,7.30906188,7.3466544,7.435941,2.6224277,4.6058204,-3.1738689,-8.4666394,-12.4637461,-17.5658783,-20.5131640,-27.15246472], [-0.28438190,3.13378000,0.6459162,4.25355975,1.4152649,2.4376976,-9.6761412,0.02187932,-0.6645711,0.3606642,1.8331504,2.71623924,1.9777180,2.9562325,2.4760033,-0.7178319,0.2636696,-2.8254923,-15.700241,1.523912893,-0.5247232,-9.36892030,1.4384727,2.5223161,8.19430823,8.3677549,7.890872,3.9614843,5.3116178,-1.1210625,-3.7869377,-12.2299418,-19.4215572,-18.1622275,-30.13097722], [1.44293648,1.20637153,1.1414142,2.51433115,0.2374252,1.3869209,-10.4010278,-0.17172748,-0.6923887,0.6294272,-0.2253935,2.52578746,2.8334157,3.9582333,4.1414271,1.1889024,3.0634975,-1.1872480,-12.491525,3.500486921,1.3208419,-2.25675567,1.8304585,3.0499592,8.47867705,8.1210759,7.891376,2.9031458,4.8709108,-1.7270202,-3.7179084,-7.9209062,-15.9554640,-18.5566503,-24.72158668], [0.08008628,4.62603760,1.6574296,5.56746031,3.1694946,5.7290775,-6.7963131,5.94101767,5.2886720,5.2493745,7.3038866,8.53525884,8.0872045,7.6511637,8.0454113,4.1708925,5.0142844,1.5327627,-8.991161,4.235801506,3.3218155,-3.46855654,4.3809403,4.7932915,9.59531312,9.0323713,8.557510,5.1095189,5.3236874,0.7534021,-0.9307901,-6.2943292,-17.2206336,-11.6649391,-23.06392179], [2.78862725,4.46691525,3.0606304,5.21979755,3.1755816,4.3574995,-5.5582310,2.70022535,1.8894892,3.8361795,3.5610540,5.65295593,6.2820975,7.2789994,7.6526760,4.3362868,6.4334170,3.5768792,-7.409683,7.498796527,6.0905478,1.42795171,6.1047254,5.8943080,10.64239070,9.3717987,8.686887,5.9250685,6.2871682,1.9758860,1.7488536,-2.4973112,-10.5589460,-11.7528010,-20.47512148], [2.37536345,5.62434193,3.2900996,6.97375430,4.3983096,7.6347339,-3.4162542,8.00257979,7.4132626,8.1088486,9.1393093,10.92889689,10.6055778,9.9285731,9.8731174,5.6424249,6.5194133,3.9606372,-5.211375,6.112069241,5.8400638,3.55102740,6.9166158,5.9703557,11.23398398,10.3671842,9.983211,7.3267346,7.0117462,4.3926516,4.0815507,-0.3518833,-12.3291644,-6.8682372,-18.85221316], [5.34041076,7.34468978,5.9584546,7.33480086,6.4349061,7.4305734,-1.6048484,6.10199134,5.3556576,7.3866334,6.6354619,8.95195965,8.8538611,8.5075497,8.8416568,5.8827597,8.7184020,4.9921868,-5.370467,8.967990652,7.8065643,1.95769240,8.2252454,8.2246540,12.64802533,11.0135827,10.389102,7.9108375,7.5827969,4.2801706,5.4360224,1.3970623,-5.6227141,-3.5623644,-15.44630881], [3.28176795,7.36828640,5.3021310,8.14535938,7.0609248,9.2559964,-1.4139702,7.96683878,7.9103694,9.5586370,9.7368154,12.13015879,12.1327986,11.7280836,12.1211915,7.6827146,9.5789288,8.1014280,-1.631818,9.310356194,9.2869823,6.28306175,9.8987568,8.5086046,13.44910111,11.6133241,10.911988,8.8986644,7.6607970,5.6802894,5.4613415,3.5715359,-7.1527991,-4.9672624,-11.76958566], [7.75155140,10.29680734,8.4807389,10.14091534,9.8194235,11.7937485,3.2392450,12.33600122,10.5322379,11.9219740,11.7313876,13.44365980,13.1557255,12.2654000,12.6904204,9.3095228,11.8651153,7.2863495,-3.060385,12.001831088,9.6890023,5.44537927,9.3907230,9.4121086,13.65387476,12.1393999,11.709668,9.2160558,8.3034706,6.7329852,7.7816949,4.2315014,-2.7899750,-0.3193406,-11.14712818], [5.56176690,10.52993970,8.7837305,10.18420702,9.6440881,12.2005752,0.7421945,11.05953772,10.3303522,12.2653273,12.4098531,14.89023264,15.0492996,14.6922166,14.7698250,10.8691227,13.8377492,11.6502332,1.791342,13.644110341,12.5389694,10.96437779,12.2546772,11.5755271,15.86374940,13.3042481,12.481285,10.7093427,8.8006986,7.1249819,8.6942008,7.8093912,-5.8064297,1.5958094,-10.16968030], [6.63220089,11.37330346,8.6072462,10.49997928,10.5625126,12.4451653,3.3075706,12.62238539,11.5777046,12.6977394,12.2631514,14.63039592,14.6635558,14.0251699,14.4288224,11.1629090,14.6459196,11.5276711,2.273678,13.761167797,13.0456366,8.78365482,12.0426678,11.0456238,14.98367316,13.0236186,12.184201,10.6733458,9.8988681,9.5416716,11.5094529,7.8699777,0.3960318,4.6556723,-6.18452537], [9.94474993,13.17538914,11.6745915,12.31626431,12.7760543,14.8675651,5.6023574,14.27111394,13.5848954,14.8899809,14.6451428,16.72980432,16.3379067,15.7455382,15.6472661,12.5722823,15.7565313,13.0742732,3.928867,14.943047240,13.5679126,13.09743656,13.7961055,13.6256148,18.38740276,14.7712995,13.991393,13.6102432,10.2321143,10.1780225,11.6324619,11.6057604,0.5160710,7.8596259,-4.12770867], [7.95298940,13.32776211,10.5839361,12.08302063,12.2454897,14.2065229,5.5598589,14.15947721,13.6375781,14.5455298,13.4670769,16.34378254,16.9830663,16.8679350,17.5658197,12.9610561,16.1844139,13.3220576,3.212292,16.601176943,14.8454638,9.69278884,13.8335246,12.9028300,16.71624542,14.4569841,13.537529,11.8696096,10.0621205,10.8540281,12.7278689,10.4799096,1.5813731,8.5093407,-2.81115335], [10.36037834,14.07602075,13.0152496,12.89955344,13.8276863,16.3226024,7.6039964,16.26630432,15.5707159,17.2464623,16.2878734,18.46768600,18.2009371,17.9923942,18.0668651,13.6597506,16.1656638,13.9894556,6.381379,15.094620073,14.1063668,13.93774341,13.7476588,13.2985518,18.41391844,15.2054300,14.328058,13.5243290,11.5394049,12.7047158,14.2949071,13.8701586,3.8881370,12.5414838,0.03698253], [13.47850733,16.92666848,15.3935043,14.57903161,17.1674107,17.7221325,10.7265562,17.48829017,17.0370324,17.3153677,16.6020366,19.00109146,18.8311194,17.3119444,17.5059949,13.7594293,17.1248747,16.1337843,7.830939,17.194190575,16.0945118,13.88117480,15.1482737,15.0994684,19.74684128,16.2103856,15.398354,14.0989721,11.1427447,11.3082150,14.0166567,14.0167336,3.7430347,11.8399283,0.61241066], [11.31107280,15.81043104,14.4957744,14.67838726,15.6159496,17.5971764,10.9851155,17.20852704,16.5111542,17.5179530,16.1379163,19.23089090,19.0196816,19.2889282,19.1579313,15.8425635,19.2500009,15.9313145,8.416428,17.826295041,16.4592620,15.33651183,14.2924235,15.0004302,18.77084949,15.3332950,14.386413,13.2126534,11.6359416,12.9261776,14.1209284,14.8367700,6.0016556,11.6242580,2.34870708], [14.04077876,16.90183801,15.4034916,15.24308992,16.5426919,17.9209480,10.3208849,18.15180140,16.9491681,17.7854868,17.1782350,19.70271761,19.8703106,19.1170352,19.6069606,16.0217570,17.9879243,17.6626595,11.265670,17.343334770,16.7174945,16.52088255,15.1506242,14.4410730,18.27433229,16.3213484,15.564584,14.0150659,11.8660692,13.1885469,15.4440148,16.6911346,6.4148856,13.9292708,2.72922471], [13.96011197,17.84660143,17.0868684,15.27698503,17.7629277,18.7257644,12.9619711,17.82269762,17.5178648,18.9016657,17.6344468,20.68491314,20.9394711,20.9067158,20.4893552,18.5216051,20.7195880,17.5025491,11.373626,19.876235249,17.9951314,16.72156595,16.2471478,16.8509148,20.54831743,16.6376503,15.171128,15.6363318,13.2080030,14.4562625,15.6210306,15.8801863,6.9191446,13.6533756,4.71961799], [16.15444153,18.40615832,17.4634452,16.83699108,18.4550642,19.4809814,12.9891387,19.55394652,18.4204521,19.0658287,17.9493436,20.60469503,20.7358576,20.2022430,19.8796224,16.3213001,19.1489248,17.1945481,10.426024,18.111273405,16.7216877,15.63995165,15.5984532,15.5885422,20.29022940,16.8172937,16.381992,14.6084508,12.4339449,13.9482513,16.7472168,17.3519630,8.0507257,15.3977248,4.11916559], [15.28101549,19.48442345,18.4222192,16.58208190,19.0799147,20.4034546,12.3876589,18.59522147,18.1926051,20.0375376,19.1972522,22.06868950,21.8224347,21.9895256,21.6848358,19.2000728,20.3852790,17.8904308,14.053057,19.475717831,18.2568270,16.78706727,15.9400998,16.3474949,20.99706398,17.6615402,16.685725,15.7189565,13.4281759,14.1127217,14.7971319,16.2040310,7.4275184,13.7240786,5.19892681], [15.69065152,18.75547526,17.9915958,17.12135898,18.8208881,19.3166517,12.2036690,19.74584158,18.8212806,19.3681299,17.9126755,21.11940493,20.8732016,20.5117654,20.2076243,17.6172724,19.2698906,17.9560228,11.551862,18.819552959,17.4652208,15.20294296,16.2988245,16.9945964,22.70444597,18.4290770,17.233053,15.8257581,13.0700583,13.6455364,15.0308775,16.7162943,9.5593400,13.2093229,3.70752628], [17.15001985,18.92746695,19.0748894,16.69799225,19.4564126,19.3965989,13.4321683,17.95050223,17.7025542,19.0903596,18.1798292,20.78658305,20.0852676,20.1076003,19.7408712,17.5312490,19.3965073,17.6408784,12.949360,19.267045103,17.3384211,16.44940610,17.0106776,17.2081026,22.28153799,17.9577981,16.596134,16.7169342,13.6946264,15.1131458,15.8989938,16.5692258,7.2831190,14.1153238,3.73424425], [16.23998729,19.76720433,18.5713082,17.66807554,19.2812512,20.1135583,11.8584419,18.66032516,17.5408604,18.9662330,18.1955621,20.98180072,21.0314424,20.5809283,20.2120311,17.6834397,19.1064222,18.3748167,11.259717,19.355017115,17.8459498,16.84927921,17.0056802,17.2961419,21.76814278,18.1896366,17.012443,16.1325775,14.0468356,13.3314775,13.8639636,16.5299751,7.7101595,11.4653459,3.27867569], [16.12794099,18.62645203,18.5889813,16.76488634,18.8051878,18.8448372,12.3499137,17.99374553,17.6427983,18.0840357,17.0743928,19.55967307,19.2094396,19.4899009,19.1091205,16.9168156,19.1695810,16.7506031,13.028893,18.643615656,16.4279645,14.49969338,15.1507141,16.0619212,21.32820615,18.3937492,17.275590,15.8386912,13.5359559,13.7323169,13.5960777,13.9477998,7.6804502,11.7341004,2.43661274], [15.64387040,18.40866364,17.9221674,16.83131354,17.9322092,18.3986046,10.2827070,16.93911381,15.5361702,17.5066819,16.4644769,19.11144408,19.0954613,19.0212898,19.0049135,15.8189698,17.2567711,15.7802607,10.569367,17.667966126,16.3161177,14.91411044,15.5134753,15.5266101,19.80093781,16.9256502,15.576580,14.4846408,13.1863707,12.4177482,12.6791919,14.4827761,6.5688777,11.3674291,1.53545570], [12.79359620,16.23170135,15.5667973,15.21882185,15.8977837,16.1137038,9.9804339,14.63165015,15.2759933,15.8658965,14.5673649,17.57734483,17.5553506,18.6358643,18.6035690,16.3289844,19.1834835,16.4837955,11.604999,18.112800002,15.5488114,13.60014137,13.0722930,14.6448919,19.20124459,17.0284389,15.809294,12.6280466,12.8047561,10.7134301,11.0747193,12.8739780,6.5714528,9.0752035,1.10550596], [15.08516028,17.90006711,16.9448624,16.75246953,17.3658120,17.7740393,8.5004046,16.60187794,15.4982832,17.1825619,16.5865167,19.32721384,18.9354925,19.4891941,19.1172913,14.7509607,15.9151419,13.3530649,9.383003,15.276232027,13.5469180,11.06689384,13.3730589,13.4129192,17.79747334,16.2280456,15.459368,13.3788151,13.0199172,10.5572005,9.6329370,11.3170225,4.6762655,7.6006416,-0.79905241], [10.78289690,14.47669158,13.8238349,14.22815022,14.1748350,13.9237242,8.6794818,14.18556801,12.8436996,14.4271252,13.1989687,16.32627553,16.4047091,17.1963066,17.5517647,14.8077176,16.2256640,12.6032681,7.721803,14.927549353,12.7243074,9.48818538,11.8489597,13.0953215,17.84982374,16.0778327,15.196319,11.5995204,12.1608256,8.1743178,8.1160990,8.0798914,4.9026390,4.9371922,-1.21718016], [13.99273754,15.50079467,14.9949921,14.93749192,15.0021221,14.7899618,5.7749761,13.55375583,12.7265629,13.7577699,13.1902275,15.87948238,15.7716376,17.0053990,16.6843186,11.4158422,12.0558886,10.4797533,6.043461,12.084568801,10.2639706,7.72879852,9.9039655,10.2334800,15.00236833,14.6244534,13.990736,10.0876059,11.8353169,8.2420726,6.7594788,7.3738997,3.3528117,4.9267821,-3.72972257], [10.06200774,12.68446041,11.9690108,12.59243890,12.1598482,11.8617838,4.8832306,10.02672006,10.0419269,11.7653119,11.6248196,13.88519017,13.4475422,14.3819129,14.7652603,11.4433178,13.0217375,10.3340757,6.499276,11.618437418,10.2272854,8.45274256,9.7668102,9.9811098,15.19667146,14.3079556,13.791435,9.3698499,10.8156373,7.9405528,7.8982466,8.0894615,2.0187616,4.9836398,-5.09599482], [12.42557107,13.69006443,13.3810861,13.58810625,13.3593090,12.3241962,4.9797237,11.92330807,10.4580388,11.8202987,11.0180479,13.54418412,13.0921786,14.2968139,14.1485108,8.7477045,9.3914968,7.6969052,3.816280,8.950168951,7.7238879,5.11928599,8.8499366,9.8183456,13.37678680,13.1822331,12.913094,9.6381528,11.1053389,5.4844602,3.0086682,4.5170500,1.0131850,0.1655786,-6.63329227], [9.44780752,11.67777673,11.1595699,11.45592375,11.3407007,10.6600115,1.4960054,8.69990197,7.6467360,9.0586306,9.2832487,11.41895937,10.8415629,11.3491082,11.5185148,8.2885222,9.7320581,7.0680880,1.671535,9.819136785,7.9740234,4.69991069,8.6763420,9.6369055,13.18475449,13.3488565,13.044499,7.8535190,10.1596375,5.2031461,2.9933094,3.9422158,-1.1822174,-0.4132347,-9.90814058], [9.39043577,10.94192260,10.6960418,11.77340371,10.4480535,9.8238850,0.6867065,8.04249841,7.2698812,9.5964186,8.9367242,11.01813080,10.5003052,11.4080792,11.7093164,7.1883869,7.9193858,5.5084212,1.065715,7.266047366,5.8817304,2.51975214,7.8075338,7.8691096,10.81320895,11.1575851,10.773591,7.4466548,9.8080261,2.7868908,0.2950122,1.3185188,-1.7257130,-3.4425979,-9.56914592], [8.01178542,9.04214861,8.9256799,9.24991444,8.7088851,7.4847627,0.4679703,6.87163726,5.7714836,6.3711553,6.0309892,8.33577412,7.7804622,9.4062981,9.1793003,6.4858308,7.0481253,5.1526448,0.654530,6.509578369,6.0674788,3.30286949,6.2532573,7.9582931,10.30007498,11.6994062,11.561345,6.1575390,8.9128411,4.2115341,0.7321533,2.4134667,-3.0694999,-2.6654362,-14.11745894], [6.70396945,8.34282585,7.9767053,9.31930293,7.8859203,7.4284277,-1.1840168,6.39961910,4.8334819,6.9432525,7.1609281,8.73660367,8.6738985,8.8613907,9.6000808,5.0414683,4.9572673,2.9891769,-2.278423,4.900125819,2.9457291,0.01645074,4.2200297,4.3201229,6.72049032,8.6247332,8.441946,3.5066799,7.1129361,0.2935737,-4.5729094,-2.8546655,-3.5243683,-9.8189285,-14.21245958], [6.14896945,7.72286509,7.7987322,8.31789462,6.9760608,6.6089520,-3.5995742,4.68831967,4.4133096,5.5657862,5.7459270,7.46267791,6.7080525,8.2557840,8.0278100,3.8852753,4.9137235,2.8520594,-2.107654,4.377519713,3.6922091,-1.03899512,3.8202570,5.7998064,8.57824177,10.0133692,9.866610,4.8246666,7.9846629,-0.8123301,-7.7006866,-2.2481007,-7.4548679,-10.3211006,-18.08823068], [4.96772535,6.26474216,5.8146683,7.79571183,5.9419961,5.0335052,-2.9789522,4.07393708,2.7225232,4.1376575,4.1878076,5.82289813,5.5493598,6.4895800,6.6995017,3.8372869,2.7270333,0.3629099,-4.915187,1.852587597,-0.7226985,-2.42844406,0.1458071,1.5736269,5.77525450,8.2156388,7.708862,1.7420744,5.7478815,-3.6457478,-9.1510823,-5.8591740,-7.9301543,-14.1171808,-18.90632863], [4.92384866,6.38167282,6.4305248,6.99305653,5.8715601,4.7877773,-5.8851950,2.61816061,2.0261661,3.1965992,3.6241719,5.70969292,5.3333796,6.9578350,7.3324076,1.8301880,0.8898593,-1.8213249,-6.853284,0.001646043,-1.4870871,-5.20716549,0.4230321,2.1330794,5.05490385,7.9248330,7.859559,1.0336071,7.3191185,-5.2532804,-12.9757178,-8.3131788,-9.0634003,-17.2805262,-20.64795819], [3.96407914,4.71065095,4.3080118,6.43604546,3.9173842,2.8902435,-6.0531055,1.72126902,-0.4912465,1.2586537,2.2547166,3.35523779,2.0786009,3.4590790,3.4619226,-1.9597608,-3.9918481,-5.9963992,-11.016791,-3.687180051,-6.0105318,-10.35183414,-5.2703551,-2.4203892,2.67592884,7.2207322,7.171602,-2.0010464,3.7642048,-8.5436299,-13.9537370,-12.4567002,-10.4213426,-18.3215561,-22.63702162], [3.61404935,3.10552156,3.7882917,4.70153038,2.7536109,1.2026077,-10.5849087,-2.51844943,-2.7636949,-0.4980151,-0.6945128,1.27459509,1.1848413,3.6227119,3.5483347,-2.2777595,-2.6964233,-4.8344045,-11.136695,-2.676585930,-4.4787300,-7.96256336,-2.7890556,-0.9226786,3.32245791,6.1738905,6.126539,-0.9169530,4.6324861,-9.3273222,-18.8143571,-12.0028873,-12.8814232,-19.8154777,-24.70115158], [1.20389635,1.35963275,1.9539900,3.22272399,0.6969988,-1.0848004,-9.4023618,-3.14035383,-4.2519269,-2.3740260,-1.7769336,-0.30224286,-1.0101960,1.2902209,1.6925426,-3.3843618,-6.8924891,-10.5917859,-14.943662,-7.702276113,-10.7431091,-15.87038426,-10.1340890,-6.6590733,-0.02827618,5.4667604,5.489291,-6.1010760,1.9533186,-13.7567048,-21.0156906,-19.9199107,-13.4152879,-24.6938089,-25.29142137], [1.97430446,1.64103847,2.2615271,4.02776982,0.6716980,-0.5613943,-13.1318340,-3.16649724,-4.8896414,-2.8625090,-2.2044736,-0.07240792,-0.7719204,2.8236704,2.2188172,-6.2926418,-9.7179623,-13.6609624,-16.610605,-10.048308051,-13.1904432,-18.06822657,-8.3097435,-5.6319387,-2.00153301,3.3523849,3.595994,-5.7210721,3.5740717,-10.7183980,-20.0035235,-18.6790645,-16.4409728,-27.5979646,-28.15301110], [-0.05243898,0.44574724,0.6184050,2.04369706,-0.4364942,-2.6847713,-13.6583680,-5.81638868,-6.6399419,-4.9679900,-4.0517668,-2.95764245,-3.7983902,-1.9636684,-1.4990582,-8.8622091,-11.1604961,-13.2289911,-19.261660,-10.394073226,-12.7112300,-16.43788178,-10.7832181,-6.1363436,-0.93562015,5.1135213,5.235177,-6.4565751,1.8122679,-14.1102531,-25.0830862,-19.8034053,-20.6518419,-22.7562888,-26.91212448], [-0.86600018,-1.91910534,-0.7710663,0.56846325,-3.1323055,-5.6916354,-17.3258726,-9.09444163,-10.0239163,-7.4337484,-7.7800294,-5.67543978,-6.3869941,-1.3676656,-1.6552448,-8.9381856,-12.5047276,-17.4041233,-21.546876,-12.438651712,-16.6830542,-23.32830143,-11.7667705,-8.8113593,-2.47290937,3.6241662,3.797163,-7.8063480,2.4575019,-18.2046602,-26.8233763,-26.4932049,-20.5733666,-29.6131413,-29.33262202], [-2.36681521,-2.28709635,-1.8696157,0.36984720,-3.4646697,-5.4388369,-22.0778966,-10.93681672,-12.4414012,-8.6085119,-6.7665983,-5.49997859,-6.4099822,-3.3492713,-3.2421660,-12.0344871,-15.4417533,-18.8599383,-23.843647,-13.268093879,-16.6119621,-22.98511491,-10.9683242,-5.0338466,-1.49800348,4.2612196,4.379706,-5.1570193,3.2951969,-11.9672283,-23.8122820,-22.2766458,-24.8000403,-25.3475608,-28.94824717], [-2.22202954,-6.01003696,-3.4194820,-3.57855162,-6.4891505,-10.0246229,-21.0748796,-15.81201371,-14.6861033,-11.7707553,-11.6690969,-10.15682677,-10.6388549,-4.1848082,-4.3385541,-10.6883512,-14.0571422,-17.3078938,-22.508033,-14.496587509,-17.4244243,-20.51630999,-13.7884742,-10.8110716,-4.73169849,2.6897291,3.325409,-11.4132849,0.5194959,-17.6908346,-25.2216513,-24.2618054,-21.7975819,-24.8938772,-29.20356636], [-1.99789179,-2.35686639,-2.4379416,0.77563101,-4.5679244,-6.1146924,-21.2243009,-9.55365057,-12.1823680,-8.5695650,-6.8129885,-5.64331998,-6.4717129,-4.6993326,-4.6474165,-15.0633070,-18.7391812,-20.5460257,-25.953251,-13.523940142,-17.1322568,-26.18912109,-10.1002041,-5.5574602,-2.30527988,4.2627107,4.591206,-5.0209158,3.3079860,-14.7589635,-24.9075085,-25.9240261,-23.9952065,-28.6793716,-31.15436023], [-3.63233304,-5.67695663,-4.6308154,-3.16248368,-6.9785392,-9.5818580,-22.6455082,-14.83908584,-15.7423369,-12.9798345,-13.0307011,-11.85702197,-12.7730906,-5.5387227,-5.5124144,-12.9141375,-15.2780961,-18.9509330,-23.509464,-16.778341025,-20.7558574,-23.56480091,-16.2670208,-12.1965631,-7.95351219,0.6510945,1.785466,-14.0185199,-1.4558668,-18.4388269,-27.5324776,-25.1714936,-22.9421665,-24.4895386,-28.18800137], [-3.89561940,-5.19735839,-4.1212035,-2.02522091,-6.2662870,-8.9261642,-21.8459764,-13.35132436,-15.2587448,-11.5253093,-9.5662205,-8.27946771,-8.6474265,-4.9232097,-4.8407829,-14.3521569,-18.7858970,-21.7140720,-26.090517,-17.079833142,-19.4892870,-22.90887280,-12.7777684,-9.4750011,-5.92622484,2.5388369,3.150377,-10.9882030,0.4669286,-15.9150718,-27.1891180,-25.2082659,-24.9632931,-24.4061756,-30.99918375]] ) - x1 = FDataBasis(x1basis, np.transpose(x1coefficients)) - - beta0 = Constant(domain_range=(0, 365)) - beta1 = BSpline(domain_range=(0, 365), nbasis=5) - - functional = ScalarRegression([beta0, beta1]) - - functional.fit(y, [x0, x1]) - - testing.assert_allclose(functional.beta[1].coefficients.round(3), - np.array([[- 0.0034528829, 0.0010604239, 0.0002112618, - 0.0020050827, 0.0051286620]]).round(3)) - - def test_Fourier(self): - - y = [3.170496, 3.162863, 3.168615, 3.101231, 3.079796, 3.052271, 2.903741, 2.952599, 2.969136, 3.082283, 3.045049, 2.973497, 2.960328, 2.893540, 2.981366, 2.847758, 2.706888, 2.652343, 2.611511, 2.569491, 2.609167, 2.559548, 2.667640, 2.602603, 2.434409, 3.062620, 2.930236, 2.784546, 3.413601, 2.434090, 2.515476, 2.428297, 2.617525, 2.415140, 2.158362] - - x0 = FDataBasis(Monomial(domain_range=(0, 365), nbasis=1), np.ones((35,1))) - x1basis = Fourier(domain_range=(0, 365), nbasis=65) - x1coefficients = np.array([[89.59970707, 1.174930e+02, 105.26055083, 1.301337e+02, 99.96350074, 1.005497e+02, -96.65546155, 59.2254168, 42.91552741, 77.96399327, 78.70202100, 117.12657116, 1.110496e+02, 138.82249275, 1.397123e+02, 47.02963944, 47.24424325, -2.889300e+00, -135.38359758, 5.245231e+01, 1.303849e+01, -65.12440445, 4.312490e+01, 76.1634150, 1.671345e+02, 190.26983010, 183.86312129, 73.26888068, 133.787154614, -1.627325e+01, -91.52590710, -95.97501045, -184.4127164, -176.52995213, -315.58275141], [-74.67672652, -7.574673e+01, -85.15643810, -6.601327e+01, -83.87111523, -7.021954e+01, -98.06174409, -76.5849625, -75.88594192, -73.32390869, -67.46233198, -71.17436876, -6.956544e+01, -68.33940763, -6.726469e+01, -71.48143615, -72.30647547, -7.684052e+01, -118.51218788, -6.220087e+01, -6.383014e+01, -84.94995574, -4.925358e+01, -45.6051376, -3.423479e+01, -30.51656702, -28.94391617, -34.56452012, -32.633211110, -4.503430e+01, -50.22893925, -95.51811954, -121.8774966, -98.96479038, -111.60883417], [-115.17887797, -1.497403e+02, -137.09440756, -1.190519e+02, -158.92545942, -1.816266e+02, -221.18086933, -211.4821603, -213.56471876, -200.18276086, -188.11022053, -197.33164610, -2.013817e+02, -170.81595630, -1.706599e+02, -202.60357163, -244.23067752, -2.491603e+02, -239.12452551, -2.306004e+02, -2.438912e+02, -274.86941043, -2.007684e+02, -168.3679298, -1.650695e+02, -92.64319338, -82.89600682, -162.32212326, -75.031989426, -2.089919e+02, -296.89430969, -286.83141439, -211.1464896, -285.79300980, -233.47281556], [5.53204090, -1.130089e+00, 0.52842204, -2.529557e+00, -4.33875191, -7.285767e+00, -9.34989482, -17.3517982, -16.79521918, -13.16131373, -15.10706906, -13.64492334, -1.336251e+01, -6.68886249, -8.759763e+00, -8.59849662, -13.83778943, -9.999166e+00, -3.73673987, -7.144765e+00, -1.224332e+01, -16.78243867, -5.401828e+00, 5.1018233, 1.184882e+01, 12.73523631, 11.89294975, 6.34183548, 10.595544948, 4.244354e+00, -5.78542095, -8.64259411, -9.1706692, 11.14716776, 12.06788481], [4.23416905, -1.094302e+00, 4.44785457, -3.921093e+00, -0.46197323, -6.632285e+00, -7.90683222, -14.9542548, -14.60033008, -9.01383237, -10.77893449, -10.04422090, -1.041400e+01, -3.44100999, -4.604680e+00, -17.36428915, -23.90220629, -2.296112e+01, -10.33599114, -2.145569e+01, -2.587159e+01, -20.52740621, -2.002035e+01, -12.1712595, -1.408536e+01, -1.86240728, -1.03630630, -18.06265481, -4.271425424, -2.053210e+01, -27.49771490, -7.17812126, 6.7410286, 23.62586533, 33.97943929], [-8.77771637, -7.837793e+00, -10.51503459, -5.876606e+00, -9.53902590, -8.950795e+00, -7.96775217, -9.2985688, -10.19042451, -6.61918797, -6.94284275, -7.38161195, -6.848871e+00, -7.05336990, -5.656784e+00, -9.45126385, -4.11766735, -2.002758e+00, -0.96182170, -2.999917e+00, -3.405476e-01, -0.29258681, 3.696487e+00, 2.0932434, -6.022455e+00, -0.15923433, 1.07208902, -1.79542350, 1.574204102, 7.547922e-01, 0.52053437, 5.80414816, 4.3150333, 17.10460123, 9.71564993], [-1.51415837, -2.946328e+00, -1.19506263, -7.124769e-01, -3.57660645, -5.908690e+00, -9.60105427, -8.8099624, -7.68376218, -5.01335967, -4.13543242, -3.76125298, -3.824409e+00, -0.58156824, -3.714378e-01, -5.37422048, -4.73805333, -8.651536e+00, -4.32810391, -3.306290e+00, -4.078670e+00, -6.74524954, -2.018126e-01, -2.8466987, -4.840585e+00, -0.69245669, -0.03545445, -6.60561886, -1.478619562, -5.844820e+00, 1.97271926, -4.13375283, -0.6109633, 4.50542683, 4.00067677], [3.19273792, 1.010561e+00, 1.50781048, 6.207182e-01, 1.26263576, 1.667300e+00, 5.33530014, 2.2976652, 2.18630617, 1.78337371, 2.96173905, 2.06155580, 2.046629e+00, -0.67518222, -1.207833e+00, 3.77542026, 4.80544821, 7.294024e+00, 8.88106241, 5.367792e+00, 8.790006e+00, 8.46637718, 7.963180e+00, 5.9721721, -6.686825e-02, 0.29232727, 0.40864262, 2.66183732, -0.037728177, 1.589603e+00, 1.42045600, 7.86628306, 6.3373820, -0.97133252, -0.09477871], [0.93816015, -1.110971e+00, 0.54196299, -1.461903e+00, -0.84095617, -2.490436e+00, -3.05602692, -3.4011579, -2.51379321, -3.60579532, -3.38203972, -3.76048310, -3.974735e+00, -1.84245223, -1.396258e+00, -3.15492736, -3.05960088, -3.599763e+00, -2.79276108, -3.379715e+00, -2.539994e+00, -3.44772241, -2.340569e-02, -0.8692101, -4.452914e+00, -3.49084735, -1.64765581, -3.77231123, -1.698689830, 1.716089e+00, 8.19121224, 0.52454112, -2.3580422, 8.36202710, 1.43949676], [-0.32883231, 1.819621e+00, 1.00113530, 1.192337e+00, 1.26104761, 2.671245e+00, 3.85644781, 3.3667360, 3.81137706, 1.41261695, 1.71090156, 1.05317196, 1.265023e+00, 1.17510383, 1.676179e+00, 1.22953838, 2.97153947, 2.446238e+00, 0.12602771, 2.270051e+00, 1.120319e+00, -2.38128173, -1.344594e+00, -1.0704945, -1.093017e+00, -0.25113863, -0.48307419, -1.73251272, -1.461257822, -4.698930e+00, -3.90412925, -3.58681205, 2.7387934, -4.72929209, -2.56319254], [0.11601629, 7.660468e-01, 1.50644743, 8.912022e-02, 0.96960619, 1.185724e+00, -3.52462967, -1.2475196, -0.97024300, 0.11505421, -0.12691757, -1.02443267, -1.297848e+00, -1.49376148, -1.013824e+00, 1.17623160, 1.68671168, 3.243256e+00, 0.77789688, 3.327439e+00, 2.786415e+00, 3.41517012, 4.363606e+00, 3.6867715, 2.580380e+00, 1.01183401, 0.89280618, 2.53820860, 0.245348118, 2.748592e+00, 4.03296208, 4.84482840, -0.4626386, 5.75765047, -0.95966081], [1.85054290, 1.655562e+00, 1.48548487, 1.725964e+00, 1.73480788, 1.729848e+00, 2.52923882, 1.1428430, 0.38652710, 1.31141257, 1.44514759, 1.68859090, 1.435771e+00, 1.38363500, 1.634005e+00, -0.29474860, -2.29333598, -3.291515e+00, -1.79756811, -3.803962e+00, -4.081570e+00, -6.87963706, -4.705254e+00, -4.5381878, -1.496349e+00, -0.64357831, 0.04523418, -3.18568596, -1.597410425, -5.711994e+00, -3.26221352, -4.96024097, 2.4892142, -3.47805988, -0.98560047], [-1.92514224, -1.901784e+00, -1.97326589, -4.851519e-01, -2.18719545, -1.403135e+00, -0.22178334, -0.8665782, -0.48234355, 0.41499621, -0.34390321, 0.40307443, 1.644554e+00, 1.81715202, 2.610074e+00, 2.60035175, 2.19658230, 2.930277e+00, 4.80050417, 5.501741e-01, 1.439555e+00, 6.59164448, -2.342761e+00, -3.0053935, -3.550229e+00, -1.55360864, -1.46798140, -4.04960581, -1.591418235, -2.246921e+00, -0.61547511, 5.15601818, 1.9540730, 4.17039396, 2.36170647], [1.00374756, 5.684724e-02, 0.54922198, 5.055470e-01, -0.20610074, -5.449527e-01, 0.72483725, -0.5773061, -0.43579344, -0.36483489, -1.02388369, -1.12723853, -1.187680e+00, -2.11135729, -1.352816e+00, -2.71631074, -2.89474411, -1.125570e+00, 0.11786105, -2.422268e+00, -1.166742e+00, -0.27168351, -1.031490e+00, -1.2818386, -6.442153e-01, -0.32753027, -0.56083303, -1.34982703, -1.382271157, -1.943469e+00, -0.94881097, 0.89495146, 0.3965252, 2.60077158, -1.76550223], [0.52399639, -5.655853e-02, -0.27545732, -1.692510e-01, -0.40510831, -6.253891e-01, -2.18746609, -0.1664321, -0.07768066, -0.20619656, 0.12224883, 0.51634225, 5.523473e-01, 1.03214516, 8.086379e-01, 0.76901682, 0.15913825, 9.792238e-01, 0.85253148, -5.696796e-02, -5.626048e-03, 4.10350760, -1.963309e-01, -1.5263529, 1.032630e+00, 0.48482088, 0.79333156, -1.44946223, -1.268912266, -1.445311e+00, 0.29633510, 2.79746146, 1.8796945, 3.16943020, -0.29738563], [-2.87812864, -6.006869e-01, -1.33765878, -2.417520e-01, -2.21109056, -1.153842e+00, -1.51620001, 0.8903456, 0.11331808, -0.22120068, -0.52819274, 0.46153320, 1.546802e+00, 2.02515279, 2.173945e+00, 3.14379916, 3.51831883, 3.711914e-01, 2.28610877, 1.382766e+00, 6.722253e-01, 1.50799242, -1.736528e+00, -1.4909560, -3.032066e+00, -1.56979669, -1.60074429, -3.32896128, -1.702777138, -2.492388e+00, -2.85806959, -0.10840979, -1.2144304, -1.44284038, -2.47567087], [1.89403282, 3.387828e-01, 1.24981784, 1.476913e-01, 1.14470343, 7.248325e-01, 1.50316611, -0.5275552, -1.31309783, -1.28942861, -1.60728285, -1.37361611, -1.544886e+00, -1.80749183, -2.172358e+00, -1.10586476, -0.59817961, -4.357982e-01, -0.29983525, -3.155637e-01, -8.046187e-01, 2.83630772, -1.069000e+00, -1.5967138, -2.789890e+00, -1.19528112, -0.53442548, -3.30897780, -1.690758341, -2.138268e+00, -1.28369621, 0.26577967, 3.0773312, -2.23526053, 0.83412752], [0.12823011, 4.993544e-01, 1.02232557, 5.454701e-01, 0.94056174, 7.283190e-01, 2.25588692, 1.9841030, 1.43314246, 0.60976371, 0.15388345, 0.94202703, 4.802331e-01, -0.17118836, -1.733193e-01, -1.03902610, -2.31585595, -2.447040e+00, -1.18451441, -2.633862e+00, -3.619202e+00, -1.67590327, -2.588952e+00, -2.3208547, 7.518609e-02, 0.09311304, 0.26970617, -1.80552037, -0.690402996, -2.429605e+00, -5.03001842, -1.50444070, -0.1540092, -5.42013467, -1.10619136], [-0.05352199, -1.036108e+00, -0.77861023, -8.623670e-01, -0.52701735, 1.376449e-03, 1.30578341, 0.1081651, 0.61644607, 0.85691395, -0.76405502, -0.10679753, -1.110967e-01, -0.01503466, -2.454475e-01, -1.07275143, -2.25988039, -2.137842e+00, -1.16810626, -2.641343e+00, -1.978049e+00, -3.19345586, -1.675475e+00, -2.7590442, -1.614353e+00, -1.07275092, -0.31712193, -2.90996441, -1.819662986, -9.108038e-01, -0.44814903, -1.32223011, 4.2705897, 0.10411349, 3.04871719], [-0.63707827, -5.423252e-03, -0.54238986, -5.753046e-01, -0.81002822, -1.812182e-01, -0.72637652, -0.7321933, -0.41937328, -0.89267560, -1.67907984, -1.18129800, -7.633840e-01, -1.11395939, -1.093883e+00, -1.06157417, -1.22123165, -2.262188e+00, -0.23249279, -1.839265e+00, -2.414006e+00, -0.96824711, -1.426166e+00, -1.5246675, 7.014345e-02, 0.34850332, 0.43894888, -0.23563895, 1.003669242, -1.372659e+00, -2.48476002, -0.59369763, -1.4040417, -1.47627477, 1.32651061], [-0.20519335, -6.796379e-01, -0.84645502, -6.354323e-01, -0.74690188, -1.535576e-01, 0.60201689, 0.4156856, 0.42217671, -0.38500919, -0.16557109, -0.43869472, 1.994074e-01, -0.17451086, -1.450471e-01, 0.82473756, 0.83877575, 2.156601e+00, 2.14167561, -2.533763e-01, 8.100568e-01, 2.62674991, -1.358064e+00, -2.0745544, -3.546144e+00, -2.17179370, -2.02087092, -3.13800540, -2.179375003, -3.729858e-01, -0.61674094, 0.02363170, -0.7285647, -1.44872242, -0.33539413], [-1.51120322, -4.099126e-01, -0.70199290, -6.264074e-01, -0.14881164, -3.453822e-01, -2.60262828, -0.4963166, -0.52966599, -0.66123536, -1.58556674, -0.50794510, -9.947995e-01, -1.62037425, -1.196887e+00, 0.14784939, 0.78732258, -9.451537e-02, -0.58266866, -5.885843e-04, -2.225741e-01, -0.57161583, -1.001126e+00, 0.6662864, 1.338666e+00, 1.15763642, 1.09746517, -0.96427466, 0.289711918, -4.878167e+00, -4.79903309, -2.16201221, -4.1199779, -5.17516190, -0.15596139], [0.93772191, 1.434060e+00, 1.46562976, 1.027022e+00, 1.83216687, 2.017265e+00, 1.90922438, 1.2976072, 2.32440414, 1.72987293, 1.43640559, 1.07796853, 8.040374e-01, 0.55450693, 6.050737e-01, -0.73772029, -0.67653405, -5.558536e-01, -0.25247028, -1.500706e+00, -1.225158e+00, -1.12798080, -2.164946e+00, -1.9261836, -2.691497e+00, -1.54005768, -1.18751473, -3.32269485, -2.214593325, -1.897016e+00, -2.33774458, 0.24093075, -0.6937826, 0.17557079, 0.11115402], [-0.06481706, -1.659918e+00, -1.09388609, -1.905815e+00, -1.40679309, -2.245890e+00, -2.23589487, -4.0863735, -3.36119968, -2.54672686, -3.88066869, -3.21323873, -2.951304e+00, -2.81616882, -2.428936e+00, -1.62115870, -1.99948070, -1.944861e+00, -3.54284799, -1.526877e+00, -1.411713e+00, -2.22639667, -1.269234e+00, -0.8733168, -5.888535e-01, 0.43988955, 0.61095815, -0.66937536, -0.194240531, -2.201345e+00, -1.89931801, -1.99023634, -0.9309564, -0.06164975, 0.63689323], [1.29314791, 2.216558e+00, 1.21082603, 2.301156e+00, 2.10492772, 2.058904e+00, 3.39057323, 2.5114900, 2.20853034, 1.45677856, 1.73899204, 1.80547990, 1.249289e+00, 1.11284238, 7.591423e-01, -0.35480926, -1.60016245, -1.093994e+00, -0.73575426, -2.399513e+00, -2.037750e+00, -0.61682787, 1.962567e-01, -0.7811619, -2.499574e+00, -1.19537245, -1.32052778, -1.14084857, -0.305790921, -5.422355e-01, -0.57154810, 0.87568897, 1.1479541, 1.99979013, 0.30593450], [-0.13329982, -1.451984e+00, -1.02249882, -1.567847e+00, -1.49940589, -1.781022e+00, -1.27972730, -4.3121653, -2.67948840, -2.30545248, -2.95382964, -2.60659429, -3.147344e+00, -2.60913233, -2.673216e+00, -2.70395227, -2.64059797, -8.745954e-01, 0.88524607, -3.158829e+00, -1.532546e+00, 0.09266597, -2.914030e+00, -2.6250173, -1.342829e+00, -0.64662735, -0.43686544, -2.09865322, -1.519102101, -2.646319e+00, -1.70747529, 0.89707281, 0.3066956, 3.08995611, -1.19368680], [1.52092905, 3.259918e+00, 2.40838600, 2.871969e+00, 3.09341297, 3.933910e+00, 3.73683573, 5.7564079, 4.35764767, 3.31813996, 4.18205352, 3.10672016, 3.218608e+00, 2.67898919, 2.357314e+00, -0.70056511, -1.68801679, -1.602781e+00, -0.34567583, -2.044594e+00, -1.755141e+00, -0.45440822, -1.538904e+00, -2.5939321, -2.747498e+00, -1.28080182, -0.99409892, -2.84057656, -1.305285838, -9.766209e-01, -0.40284505, -0.27756038, -0.5397107, 4.82378296, 1.24205726], [0.12555626, -5.164036e-01, 0.04631478, -5.777127e-01, -0.22542671, 2.967644e-01, -0.61182539, -0.8243859, -0.06184360, 0.70893457, 0.43232784, 0.15735211, 6.040690e-01, 0.77280898, 7.694384e-01, -0.47463830, -1.03782668, -1.368550e+00, -1.21145518, -1.557196e+00, -2.303430e+00, -1.82390441, -2.169142e+00, -2.4186646, -1.367343e+00, -0.94630230, -0.93509247, -1.53650962, -1.321637726, -3.670951e+00, -4.37924159, -0.45592875, -3.0147021, 0.85525286, 0.58210920], [-0.55294211, 7.259003e-01, -0.13405784, 1.170870e+00, 0.67648687, 7.423729e-01, 0.03089698, 2.1762848, 1.80909252, 1.22782014, 1.48640345, 0.93741620, 9.455015e-01, 0.83294946, 1.167819e+00, -0.34111742, 0.29967272, 6.137995e-02, -1.17558891, 3.534104e-01, 6.210646e-01, -0.58457012, -4.794652e-01, -0.9427772, -1.751084e+00, -1.03318760, -1.32951928, -2.00889268, -1.899263799, -1.009080e+00, -2.77215776, -0.23279707, 0.1536449, 1.80205160, 1.72308629], [-0.84292428, -7.240828e-01, -0.18783875, -2.276676e-01, -0.17319786, -3.188255e-01, -3.19629287, -1.3641001, -0.62180837, -0.41375638, -1.34329317, -1.01205814, -7.984127e-01, -0.62797278, -6.486301e-01, -0.11435523, 0.03625112, -2.078555e+00, -1.65170346, -1.883270e+00, -1.656176e+00, -3.66675034, -3.099800e+00, -2.6033118, -1.004849e+00, -0.55472947, -0.69898102, -2.73888007, -1.580480802, -4.894187e+00, -6.31684492, -3.02072733, -1.6513180, -1.72628994, 1.64966498], [0.58700891, 2.533591e+00, 1.78137004, 2.183785e+00, 2.50339367, 2.351354e+00, 0.69156754, 2.4872038, 1.51129592, 1.83545066, 2.64303869, 1.91865134, 1.576801e+00, 0.75216425, 5.087367e-02, -0.18824949, 1.21772474, 2.707736e-01, -1.90104702, 1.688371e+00, 1.813622e+00, -0.06301625, 1.736327e+00, 1.8251318, 1.531628e+00, 0.56639938, 0.73485081, 1.69305992, 0.329180660, -1.099868e+00, -2.64154447, -2.15795069, -0.6603238, -0.45553474, -0.91044727], [-0.83415793, -9.237381e-01, -0.13605307, -6.640327e-01, -0.69872888, -1.053862e+00, -2.57543265, -2.2838630, -0.86367214, -0.42553165, -0.41312104, -0.41829424, -2.947966e-01, 0.61267035, 3.054356e-01, 0.30492975, -0.88056265, -2.053943e+00, -1.88146693, -1.818735e+00, -1.742755e+00, -1.07010395, -2.092101e+00, -2.2934475, -9.921462e-01, -0.65940619, -1.29734698, -2.14460743, -0.149219082, -1.123322e+00, -0.85028388, -1.09243236, -1.7484155, 1.07965678, -1.41680460], [-1.09559400, 8.752225e-01, 0.11894841, 1.412295e+00, 0.35075438, 1.177684e+00, 0.22637718, 1.4511574, 0.64680082, 1.02591542, 1.93749832, 1.52391763, 1.727282e+00, 2.24265094, 2.505160e+00, 2.25250414, 0.91597471, 1.836988e-01, -0.87855461, 2.178514e+00, 1.143963e+00, -0.21672121, 1.370155e+00, 0.6991980, 9.343378e-01, 0.33245579, 0.01881070, 1.36815233, -0.218940400, 9.519239e-02, -0.91836698, -2.25471571, 0.8385579, -0.51322330, -1.57342169], [-0.02387740, 1.928508e+00, 0.99791004, 2.009739e+00, 1.81165891, 2.394173e+00, 1.78346542, 2.2227813, 2.03253527, 1.61895789, 3.51168616, 3.13739560, 2.845314e+00, 3.71095248, 3.776803e+00, 1.39810535, -1.57526756, -3.222592e+00, -2.16501910, -2.499120e+00, -2.771815e+00, -3.00282112, -2.332177e+00, -2.3011830, -1.768804e+00, -0.72479856, -0.28197745, -1.58254826, -0.546327301, -1.085810e+00, -2.00566426, -2.90734205, 0.6635186, -3.25554509, -0.49570402], [0.70193942, 8.983500e-01, 0.46959549, 8.356255e-01, 1.43694426, 1.164194e+00, 2.69286894, 0.2766771, 1.08325737, 0.63212413, 1.07172161, 1.12083776, 1.142423e+00, 0.98642962, 8.381407e-01, 2.14839871, 2.40860798, 1.596700e+00, 2.00761769, 1.029942e+00, 5.332955e-01, 1.00251691, 1.477372e+00, 0.8825888, 7.543783e-01, -0.71170156, -0.66228059, 0.72120461, -0.112047972, 8.918786e-01, 2.81122334, 1.18774046, 1.6183223, 1.43545417, 1.96891353], [0.39181669, 1.288237e+00, 1.60737757, 7.814422e-01, 1.88927026, 1.701027e+00, 1.06322454, 2.1590953, 1.95923291, 1.52144924, 2.96889237, 2.84039247, 2.529072e+00, 1.72091607, 1.980772e+00, -0.26682328, -2.36439358, -3.115120e+00, -1.44706064, -3.195354e+00, -3.193999e+00, -4.53016456, -1.851243e+00, -2.2578876, -1.250965e+00, -1.00037953, -1.02811886, -1.91047284, -0.791022341, -1.868363e+00, -0.79503202, -3.12065068, 1.0982350, -0.98791805, 1.17235759], [-0.84975042, -6.042669e-01, -0.69995578, -3.855678e-01, -0.81858779, -4.477763e-01, -0.48064829, 1.0503627, -0.34075923, 0.05409725, -0.70536980, 0.15810693, 7.651033e-01, 0.53022488, 4.582444e-01, 2.25153660, 1.85012782, 4.967006e-01, 2.25063341, 6.230297e-01, 5.676067e-01, 0.50763531, 8.492886e-01, 0.4962994, -1.358876e+00, -0.44359800, -0.17973976, -0.38368966, 0.129729312, 2.040848e+00, 3.53169871, 0.72533970, 1.0386347, 0.49064948, 0.02608175], [0.92537534, 7.075020e-01, 0.82971380, 1.044658e+00, 0.46269672, 1.620570e+00, -0.93178459, 0.5179339, 0.88022346, 0.99439406, 1.47013324, 1.47939859, 1.497188e+00, 0.92313668, 4.794219e-01, -0.38780418, 0.19988400, -1.184432e+00, -1.56908773, -4.425361e-01, -1.032698e+00, -1.26721281, -2.000995e+00, -2.6358795, -2.502450e+00, -1.26815114, -0.77843387, -3.36357365, -1.536487004, -1.919820e+00, 0.64204764, 0.35792749, -1.4945154, 1.43738486, -0.58339537], [1.20178509, -2.000682e-01, 0.01693073, 2.133457e-01, 0.30763788, -2.667839e-01, 1.08970769, 0.2702089, -1.63401658, -1.05500883, -1.42481123, -1.28505382, -9.258686e-01, -1.41616250, -1.843747e+00, 0.11481250, 0.93181784, 5.366704e-01, -0.89659028, 2.318438e+00, 1.872510e+00, 1.74334003, 2.854597e+00, 3.3555994, 2.925989e-02, -0.48783305, -0.47083650, 0.74844394, 0.559798914, 4.525265e+00, 4.58477169, 1.99706784, 1.5080084, 0.06314047, -0.59944915], [2.07722881, 5.341312e-01, 0.77947626, 6.037828e-01, 1.17211573, 5.383573e-01, 0.77166599, 0.3726998, 0.25550811, 0.59648554, 0.38012092, 0.07006915, 5.868517e-03, -0.74238151, -1.497909e+00, 0.47577400, 1.06051627, 1.617059e+00, 1.80032305, 7.479881e-01, 2.043964e+00, 2.52557356, 1.219913e+00, 0.4803534, 4.667791e-01, 0.38887412, 0.38644969, 0.70774447, -0.336216608, 1.632456e+00, 3.00018553, 3.45481265, 0.7790756, 4.13703668, 0.06337106], [-2.24989837, -4.302763e-01, -1.74929544, -3.740111e-01, -1.07219551, -1.335091e-01, -3.16035682, -1.6740220, -1.90681805, -0.82402832, 0.16596253, -0.05064610, 9.294415e-02, 0.48398348, 6.499076e-01, 0.47855573, 0.35938927, 2.321464e+00, 2.41447364, 1.441026e+00, 2.963557e+00, 2.82913177, 3.384105e+00, 3.4473668, -4.024473e-01, -0.17129249, -0.29503759, 1.54836250, 1.513930909, 6.481393e+00, 4.51432690, 4.07080734, 0.8138094, 1.81949946, 0.57143108], [-1.01656994, -8.890316e-01, -1.29461899, -7.901383e-01, -0.68447103, -1.201129e+00, 0.28967262, -1.1334414, -1.77287724, -1.21672304, -1.69278076, -1.21148979, -5.146720e-01, -0.64248992, -4.680204e-01, 0.90215798, 1.42263597, 2.830218e+00, 1.05815930, 1.898014e+00, 3.858844e+00, 4.42466365, 2.854507e+00, 2.4693304, -2.348306e-02, -0.25772640, -0.37533300, 0.18576537, -0.361076485, 3.383409e+00, 3.70668446, 4.64448898, 0.2705007, 6.36882615, 0.10410477], [-0.50992353, -1.184197e-01, -0.12051278, 2.810264e-01, -0.11147047, 3.340698e-01, 0.03633611, 0.4288639, 0.58526527, 0.50172923, 0.90255473, 0.81175872, 4.213462e-01, 0.56000628, 2.846546e-01, -0.22039297, 0.48549177, 3.008412e-01, 0.16184718, 9.609462e-01, 1.354041e+00, 2.34607497, 2.872148e+00, 4.4758467, 1.733022e+00, 0.19684940, 0.18258232, 3.00324295, 0.889483650, 5.485918e+00, 3.83386430, 3.46360979, -1.4687842, 2.54672368, -0.27029831], [0.45902810, 1.699793e-01, 0.21554644, -5.523539e-01, 0.57907943, 3.932309e-01, 1.06041236, 0.6304128, 1.47611716, 0.54732013, 0.70397469, 0.91704818, 1.414214e+00, 0.11566881, 2.830642e-01, 1.81901051, 1.94932693, 2.850322e+00, 0.94791735, 2.498273e+00, 3.156392e+00, 6.11465651, 2.362363e+00, 2.9962383, 9.802733e-01, 0.57738645, 0.65135804, 1.07888362, 0.240927614, 2.919394e+00, 2.38432923, 3.48394353, 0.1052820, 4.75600853, 0.71530482], [-0.24349431, -8.439431e-01, -0.66341302, -4.697805e-01, -0.71829961, -2.732005e-01, -0.84448579, -0.8273185, -0.23995555, -0.11808013, -0.08633229, -0.39362060, -3.178583e-01, 0.17521182, -1.559364e-01, -0.38649251, -0.32610195, 9.177964e-01, 0.63200688, -2.276129e-01, 3.370388e-01, 3.90440681, 6.545717e-01, 1.4367193, -9.454337e-03, -0.29152578, -0.22592465, 1.39639653, 0.849624428, 2.994090e+00, 1.29402269, 2.44582665, -1.0828134, -0.94532233, 0.40325778], [-1.39223110, -1.622055e+00, -1.20778530, -1.069178e+00, -1.43637684, -1.159123e+00, -1.22136458, -1.6760825, -0.11519447, 0.25278374, -0.27100938, -0.23787892, 2.735731e-01, -0.17959874, 2.264140e-01, 0.19796455, 1.21407987, 1.426652e+00, 0.11194166, 8.415828e-01, 1.649631e+00, 3.54137935, 1.501209e+00, 0.3463514, -8.164126e-01, -0.88062879, -0.72262526, -0.01791845, 0.235655888, 2.037726e+00, 2.34970417, 1.09734909, 0.0682573, 0.60407452, 0.90443122], [-1.02392565, -5.673491e-01, -0.70964602, -3.687754e-01, -0.36096089, -8.638283e-01, -1.77941475, -2.7969361, -1.42330776, -1.55866903, -1.61706674, -1.73253802, -1.134115e+00, -0.04767659, -3.778682e-01, -0.20021839, 1.00107533, 1.643225e+00, -0.20567176, 1.323667e+00, 9.398355e-01, 0.43918670, 5.056253e-01, 0.7109244, 4.193428e-01, 0.06621289, -0.24312743, -0.07837749, -0.140794080, 2.557637e-01, -1.59921821, 0.21465080, -0.8936323, -3.11880272, -1.03891844], [-0.82870195, -2.099606e-01, -0.15920175, -3.192975e-01, -0.58927883, -9.958981e-01, -0.45918863, -2.1210116, -0.80747556, 0.17178359, -0.18338013, -0.35841963, 7.590088e-02, 0.31752054, 4.301735e-01, 1.37493985, 1.52285479, 2.249923e+00, 0.67516745, 1.533434e+00, 2.182756e+00, 0.76057515, 1.629477e+00, 1.3988561, 9.473159e-01, 0.31546383, 0.03214408, 0.94171111, 0.339876393, -2.163501e+00, -1.41146876, -0.71298005, -0.6075998, -2.80944526, 0.06488881], [1.28418890, 3.649906e-01, 0.59380554, 1.759908e-03, 0.89014875, 9.566813e-01, 0.03879258, -0.4865600, -0.46720751, 0.21530793, 1.34206318, 0.19911945, 1.026378e-02, -0.05133407, 3.608472e-01, 0.60363157, 0.03253768, 3.366076e-01, 0.68156764, -5.036516e-01, -5.293649e-02, 1.04943224, 3.554001e-01, -0.5084141, 2.388126e-01, 0.07516278, 0.15713984, -0.12204924, -0.349544186, -2.160509e-01, -2.09680497, 0.76599707, -0.2259136, 0.61532440, 0.16331847], [1.00794962, -3.810249e-01, -0.12973934, 2.508333e-01, -0.17568826, 8.686412e-02, 1.37622548, 0.8415755, 1.18632403, 0.56813310, 0.86185305, 0.28815620, -2.965147e-02, 0.72854260, 3.082082e-01, 0.50788173, -0.22606523, -4.097192e-01, -0.15607617, -1.394077e+00, -8.780649e-01, -2.35070263, -1.711104e+00, -1.6499690, -3.468064e-01, 0.33841614, 0.39654237, -1.05243700, -0.331145258, -1.292601e+00, -1.25903999, -1.08741339, 1.0970864, -1.42529199, -0.72820593], [-1.58154333, -1.390164e+00, -1.20625557, -1.409198e+00, -1.04509734, -1.179929e+00, 0.07190697, -1.6489419, -0.95285604, -1.53267498, -2.04393750, -1.44254455, -1.050924e+00, -0.27768119, -2.803571e-03, 0.88536754, 1.00612466, 6.708885e-01, -0.17207077, 3.065130e-01, 9.812993e-01, 1.59942069, -8.212277e-01, -0.5297174, -1.261708e-02, -0.05160913, -0.17776824, -1.94493722, -0.547986131, -7.006174e-01, -2.50788964, -0.52733872, 0.7161020, -1.67369274, 1.50037798], [-0.45628757, -1.512576e+00, -1.24477454, -1.315945e+00, -1.36949210, -1.196824e+00, -0.09191878, -1.4566100, -1.23237563, -1.32163296, -1.26507453, -0.77997970, -4.037119e-01, 0.13454366, -1.144565e-01, 1.05612140, 0.10194231, 2.284894e-01, 0.58973367, 3.054403e-01, 5.821388e-01, 0.54479869, -7.658625e-01, -2.1371981, -7.184237e-01, -0.05619105, -0.23008535, -1.91233567, -1.059662431, -5.892829e-01, -0.07389768, -0.87797601, -1.1497557, -3.22940075, -0.51134054], [2.24355893, 2.987791e-01, 1.57003140, -6.577640e-01, 0.95599659, -3.234701e-01, 1.03488812, -1.6822629, -0.49675688, -1.40446335, -1.68761302, -1.80113977, -1.910319e+00, -1.31372456, -1.796134e+00, -0.78463456, 0.13496712, 3.548790e-01, -0.15189028, 3.527213e-01, 3.898108e-01, 1.61126487, -9.367634e-01, -0.1448268, 5.824714e-01, 0.86455257, 1.03595041, -0.73132245, -0.581587321, 4.870423e-01, 0.69273813, 0.40620599, 0.4264222, 2.22542172, -0.64908154], [0.03151138, -1.429253e+00, -0.33020856, -8.769927e-01, -1.05542294, -1.479335e+00, 0.28840750, -1.8588508, -1.91150763, -1.58731427, -2.45159863, -2.14016373, -1.856729e+00, -1.45426533, -1.499989e+00, -0.14273784, 0.65881620, -4.381416e-01, -2.25086798, 6.856435e-01, 1.284126e-01, 0.16678078, -7.691342e-01, -1.6276582, 3.319708e-01, -0.06524522, -0.13800628, -1.80628763, -0.626905087, 1.746483e-01, 2.40221924, -0.43184965, 1.4377209, -1.81368469, -0.63079462], [-0.19420235, 1.017261e+00, 0.44046808, 2.235912e-01, 0.82711704, 1.488388e-01, 1.11226175, -0.6765021, -0.50506171, -0.57656726, -0.11680901, -0.76164050, -1.120442e+00, -1.41198785, -1.707052e+00, 0.03169615, 0.42995819, -6.548027e-01, -0.92319645, 9.372992e-01, 4.472379e-01, -0.64736446, 2.350966e-03, 0.8167789, 1.532516e-01, 0.26300417, 0.27715292, -0.22968922, -0.318246824, 7.685206e-01, 1.26979359, -0.60094669, 1.6978479, 2.50160157, -1.09154674], [0.40872749, 5.316905e-01, 0.07574449, 3.738896e-01, 0.49030229, 2.154546e-01, 1.20505168, -0.3998987, 0.45921862, 0.12764587, 0.24036029, 0.04300324, 2.611705e-02, -0.38701871, -7.228944e-01, -0.06229737, 0.02990214, -1.022368e+00, -1.27149324, 1.078780e+00, 4.584915e-01, -2.08859968, 6.685534e-01, 0.1185499, -6.889925e-01, -0.50159233, -0.62806797, -0.40109349, 0.478865976, 1.012569e+00, 1.74002151, -2.58437083, 0.2119228, -1.26496072, -0.83611301], [-1.20693909, -8.648244e-02, -0.68445745, -4.231141e-02, 0.07030813, 2.977115e-02, 1.61514664, 0.3613738, 0.98519906, 0.56038201, 0.68799786, 0.25726847, 2.943262e-01, 0.03265916, 4.871384e-01, 1.08699337, -0.14340130, 1.158565e+00, 2.67213822, -1.448469e-01, -3.759568e-01, 1.47032173, 9.272117e-01, 0.6709135, 1.699794e-01, 0.30539670, 0.64835640, 0.01487555, -0.728060109, -1.132007e+00, -0.59876173, -0.01768361, 1.2546856, 0.47877009, -0.46534162], [0.74070647, 1.881216e-01, 0.10590741, 6.856033e-01, -0.01494294, 4.620404e-01, -0.37088003, -0.3991986, 0.63966992, 0.62867809, 1.13309925, 1.03074702, 1.180520e+00, 0.94804892, 3.586752e-01, -0.48021134, -1.97926806, -3.987384e-03, 0.15562854, -3.272787e-02, 9.576702e-01, 0.42574563, 2.626444e+00, 2.4926891, 1.194390e+00, 0.21078992, 0.58478490, 2.17276930, 1.311293068, 9.013753e-01, -0.58093539, 0.25607094, 1.0480987, -0.19546428, -1.78147390], [0.58252591, 4.277163e-01, 0.90430340, 1.034826e-01, 0.58591696, 5.142170e-01, 0.95038449, -0.3056090, 0.30011349, 0.97574674, 0.44413519, 0.40519577, 6.835409e-01, 2.13472092, 2.159293e+00, 1.55883723, 0.17847583, -1.351055e-01, 1.94473047, -4.080991e-01, -9.536895e-01, 2.11378246, 9.719330e-03, -0.1195651, -6.975778e-01, -0.94429705, -0.85992101, -0.02517769, 0.007567667, -1.473490e+00, -2.56068088, 0.23898731, 2.6713763, -1.15294033, 1.89343762], [0.51086899, 1.945461e-01, 0.35563999, 1.376437e-01, 0.41130673, -1.761141e-01, 0.70332114, -1.5772770, -0.40116904, -0.30775824, -0.18106109, -0.35859266, 4.523594e-02, 0.08296053, -2.474059e-01, 0.99918091, 0.16696234, -2.655998e-01, 0.11855061, 1.604159e+00, 1.353651e+00, 0.76703312, 1.713040e+00, 2.2453633, 1.112115e+00, 0.58666241, 0.77868287, 1.85247340, 1.005655963, 2.964966e-01, -0.75506257, -0.46511248, 2.5370339, 0.12240512, -0.76445357], [1.14540932, 4.230023e-01, 0.82329579, 2.522589e-01, 0.40010288, -1.159966e-01, -0.58847402, -1.4912045, -0.91540235, -0.70744602, -1.63973010, -1.16779949, -6.924016e-01, -0.12211442, 2.142196e-01, -0.95961729, -0.03517539, 6.954538e-01, -0.07720068, -7.311228e-01, -2.358224e-01, 0.61405116, -1.503198e-01, -0.9694861, -1.794021e+00, -0.68244899, -0.67482556, -1.16556735, -0.013233124, -1.698246e+00, -1.36983520, 0.31130760, 0.7389527, -0.84510408, 1.18858326], [1.74285071, 2.506958e+00, 2.76422771, 1.654996e+00, 2.28409145, 2.352266e+00, 1.72266916, 0.9046326, 2.32138310, 2.34904150, 2.85547750, 2.73975738, 2.621758e+00, 2.49867607, 2.462475e+00, 2.05542418, 0.62659544, 1.181264e+00, 0.44256888, 1.147806e-01, 1.122830e+00, 0.21694589, 7.742090e-01, 0.9181116, -1.400702e-01, -0.13253731, -0.61253172, 0.69978709, -0.192494238, -3.563360e-01, -1.27044350, -0.19247570, 0.8062723, -3.03513839, -0.75634886], [-1.85078404, -7.183344e-01, -0.38240584, -9.209318e-01, -0.55601263, -1.572229e-01, -0.30486141, -0.5511934, 0.44767040, -0.17350576, -0.27735373, 0.08864750, -9.293620e-02, 1.13317491, 7.132884e-01, 1.04196753, 2.27408855, 1.160518e+00, 1.79245281, 6.472166e-01, 7.166789e-01, 1.51546460, 1.067069e-01, 0.1304825, 5.333138e-01, 0.32225323, 0.11127284, 0.21314949, 0.462165374, 4.007278e-03, -0.03874602, 0.92968477, -2.1448894, 0.55525031, -0.53638872], [-0.24538532, 1.038213e+00, 0.22348443, 1.524884e+00, 1.02875904, 1.374557e+00, 0.95412042, 0.8511914, 1.30296742, 0.92884887, 1.71803275, 1.22633650, 1.339939e+00, 0.42608840, -1.901805e-01, -0.20046471, -0.98173732, -8.768400e-01, -1.65674072, -1.939138e-01, -4.038453e-01, -0.69586081, -7.000077e-01, -0.7295975, -2.086565e-01, 0.17521970, 0.05716848, 1.18548477, 0.272045330, -3.792776e-01, -0.73572734, -1.67429640, -1.2667567, 0.43780133, -0.54679839], [-0.20314284, -4.928191e-02, -0.47170388, -3.994800e-01, -0.01959643, 7.333726e-02, -0.04472905, 2.4390476, 1.39536759, 0.19688113, 0.13007568, 0.34144884, 3.527637e-01, 1.15212885, 7.171617e-01, 0.68313280, 0.75987149, -2.710371e-01, 0.14343316, 9.875637e-01, 3.633765e-01, -1.24790369, 5.623962e-01, 0.4395609, 9.299851e-01, 0.28522147, 0.46285969, 0.72755880, 0.186921131, -7.914515e-01, 0.01194463, 0.70348229, 0.3314508, 0.56448351, -2.60371925]]) - x1 = FDataBasis(x1basis, np.transpose(x1coefficients)) - - x1.plot() - - beta0 = Constant(domain_range=(0, 365)) - beta1 = Fourier(domain_range=(0, 365), nbasis=5) - - functional = ScalarRegression([beta0, beta1]) - - functional.fit(y, [x0, x1]) - - testing.assert_allclose(functional.beta[1].coefficients.round(3), - np.array([[0.0006011817, -0.0014284904, 0.0041845742, -0.0160814209, 0.0028040342]]).round(3)) if __name__ == '__main__': From 3ce7ea24c15ac3ead504aeb1739602202529f74f Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Mon, 20 May 2019 00:10:15 +0200 Subject: [PATCH 023/222] Update all numpy reference to has the same name --- docs/conf.py | 4 +- examples/plot_boxplot.py | 4 +- setup.py | 4 +- skfda/misc/_math.py | 28 +-- skfda/misc/kernels.py | 22 +- skfda/misc/metrics.py | 55 +++-- .../registration/_landmark_registration.py | 18 +- .../registration/_registration_utils.py | 26 +-- .../registration/_shift_registration.py | 46 ++-- .../smoothing/kernel_smoothers.py | 44 ++-- skfda/preprocessing/smoothing/validation.py | 34 +-- skfda/representation/basis.py | 206 +++++++++--------- skfda/representation/grid.py | 88 ++++---- tests/test_grid.py | 42 ++-- 14 files changed, 310 insertions(+), 311 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 35c6d9df8..959ae15f6 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -56,8 +56,8 @@ 'sphinx.ext.doctest' ] doctest_global_setup = ''' -import numpy -numpy.set_printoptions(legacy='1.13') +import numpy as np +np.set_printoptions(legacy='1.13') ''' # Add any paths that contain templates here, relative to this directory. diff --git a/examples/plot_boxplot.py b/examples/plot_boxplot.py index 23d4599d5..9850a6548 100644 --- a/examples/plot_boxplot.py +++ b/examples/plot_boxplot.py @@ -15,7 +15,7 @@ from skfda.exploratory.depth import band_depth, fraiman_muniz_depth import matplotlib.pyplot as plt from skfda.exploratory.visualization.boxplot import Boxplot -import numpy +import numpy as np ################################################################################## # First, the Canadian Weather dataset is downloaded from the package 'fda' in CRAN. @@ -37,7 +37,7 @@ colormap = plt.cm.get_cmap('seismic') label_names = dataset["target_names"] nlabels = len(label_names) -label_colors = colormap( numpy.arange(nlabels) / (nlabels - 1)) +label_colors = colormap(np.arange(nlabels) / (nlabels - 1)) plt.figure() fd_temperatures.plot(sample_labels=dataset["target"], label_colors=label_colors, diff --git a/setup.py b/setup.py index 8e21decc6..b8646f3c8 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ import os import sys -import numpy +import numpy as np from setuptools import setup, find_packages from setuptools.extension import Extension @@ -25,7 +25,7 @@ os.path.join(fdasrsf_path, 'optimum_reparam.pyx'), os.path.join(fdasrsf_path, 'dp_grid.c') ], - include_dirs=[numpy.get_include()], + include_dirs=[np.get_include()], language='c', ), ] diff --git a/skfda/misc/_math.py b/skfda/misc/_math.py index b8794a2f6..521fe2dc1 100644 --- a/skfda/misc/_math.py +++ b/skfda/misc/_math.py @@ -4,7 +4,7 @@ package. FDataBasis and FDataGrid. """ -import numpy +import numpy as np import scipy.integrate from ..exploratory.stats import mean @@ -28,7 +28,7 @@ def sqrt(fdatagrid): FDataGrid: Object whose elements are the square roots of the original. """ - return fdatagrid.copy(data_matrix=numpy.sqrt(fdatagrid.data_matrix)) + return fdatagrid.copy(data_matrix=np.sqrt(fdatagrid.data_matrix)) def absolute(fdatagrid): @@ -43,7 +43,7 @@ def absolute(fdatagrid): original. """ - return fdatagrid.copy(data_matrix=numpy.absolute(fdatagrid.data_matrix)) + return fdatagrid.copy(data_matrix=np.absolute(fdatagrid.data_matrix)) def round(fdatagrid, decimals=0): @@ -73,7 +73,7 @@ def exp(fdatagrid): the elements of the original. """ - return fdatagrid.copy(data_matrix=numpy.exp(fdatagrid.data_matrix)) + return fdatagrid.copy(data_matrix=np.exp(fdatagrid.data_matrix)) def log(fdatagrid): @@ -87,7 +87,7 @@ def log(fdatagrid): FDataGrid: Object whose elements are the logarithm of the original. """ - return fdatagrid.copy(data_matrix=numpy.log(fdatagrid.data_matrix)) + return fdatagrid.copy(data_matrix=np.log(fdatagrid.data_matrix)) def log10(fdatagrid): @@ -102,7 +102,7 @@ def log10(fdatagrid): original. """ - return fdatagrid.copy(data_matrix=numpy.log10(fdatagrid.data_matrix)) + return fdatagrid.copy(data_matrix=np.log10(fdatagrid.data_matrix)) def log2(fdatagrid): @@ -117,7 +117,7 @@ def log2(fdatagrid): original. """ - return fdatagrid.copy(data_matrix=numpy.log2(fdatagrid.data_matrix)) + return fdatagrid.copy(data_matrix=np.log2(fdatagrid.data_matrix)) def cumsum(fdatagrid): @@ -131,7 +131,7 @@ def cumsum(fdatagrid): FDataGrid: Object with the sample wise cumulative sum. """ - return fdatagrid.copy(data_matrix=numpy.cumsum(fdatagrid.data_matrix, + return fdatagrid.copy(data_matrix=np.cumsum(fdatagrid.data_matrix, axis=0)) @@ -164,16 +164,16 @@ def inner_product(fdatagrid, fdatagrid2): triangle delimited by the the lines y = 0, x = 1 and y = x; 0.5. >>> import skfda - >>> x = numpy.linspace(0,1,1001) + >>> x = np.linspace(0,1,1001) >>> fd1 = skfda.FDataGrid(x,x) - >>> fd2 = skfda.FDataGrid(numpy.ones(len(x)),x) + >>> fd2 = skfda.FDataGrid(np.ones(len(x)),x) >>> inner_product(fd1, fd2) array([[ 0.5]]) If the FDataGrid object contains more than one sample - >>> fd1 = skfda.FDataGrid([x, numpy.ones(len(x))], x) - >>> fd2 = skfda.FDataGrid([numpy.ones(len(x)), x] ,x) + >>> fd1 = skfda.FDataGrid([x, np.ones(len(x))], x) + >>> fd2 = skfda.FDataGrid([np.ones(len(x)), x] ,x) >>> inner_product(fd1, fd2).round(2) array([[ 0.5 , 0.33], [ 1. , 0.5 ]]) @@ -184,12 +184,12 @@ def inner_product(fdatagrid, fdatagrid2): "of the domain of the FDatagrid object is " "one.") # Checks - if not numpy.array_equal(fdatagrid.sample_points, + if not np.array_equal(fdatagrid.sample_points, fdatagrid2.sample_points): raise ValueError("Sample points for both objects must be equal") # Creates an empty matrix with the desired size to store the results. - matrix = numpy.empty([fdatagrid.nsamples, fdatagrid2.nsamples]) + matrix = np.empty([fdatagrid.nsamples, fdatagrid2.nsamples]) # Iterates over the different samples of both objects. for i in range(fdatagrid.nsamples): for j in range(fdatagrid2.nsamples): diff --git a/skfda/misc/kernels.py b/skfda/misc/kernels.py index f769db8b7..3831b5d4f 100644 --- a/skfda/misc/kernels.py +++ b/skfda/misc/kernels.py @@ -1,7 +1,7 @@ """Defines the most commonly used kernels.""" import math from scipy import stats -import numpy +import numpy as np __author__ = "Miguel Carbajo Berrocal" @@ -30,8 +30,8 @@ def cosine(u): \end{cases} """ - if isinstance(u, numpy.ndarray): - res = numpy.zeros(u.shape) + if isinstance(u, np.ndarray): + res = np.zeros(u.shape) res[abs(u) <= 1] = math.pi / 4 * (math.cos(math.pi * u[abs(u) <= 1] / 2)) return res @@ -51,8 +51,8 @@ def epanechnikov(u): \end{cases} """ - if isinstance(u, numpy.ndarray): - res = numpy.zeros(u.shape) + if isinstance(u, np.ndarray): + res = np.zeros(u.shape) res[abs(u) <= 1] = 0.75*(1 - u[abs(u) <= 1] ** 2) return res if abs(u) <= 1: @@ -71,8 +71,8 @@ def tri_weight(u): \end{cases} """ - if isinstance(u, numpy.ndarray): - res = numpy.zeros(u.shape) + if isinstance(u, np.ndarray): + res = np.zeros(u.shape) res[abs(u) <= 1] = 35 / 32 * (1 - u[abs(u) <= 1] ** 2) ** 3 return res if abs(u) <= 1: @@ -91,8 +91,8 @@ def quartic(u): \end{cases} """ - if isinstance(u, numpy.ndarray): - res = numpy.zeros(u.shape) + if isinstance(u, np.ndarray): + res = np.zeros(u.shape) res[abs(u) <= 1] = 15 / 16 * (1 - u[abs(u) <= 1] ** 2) ** 2 return res if abs(u) <= 1: @@ -112,8 +112,8 @@ def uniform(u): \end{cases} """ - if isinstance(u, numpy.ndarray): - res = numpy.zeros(u.shape) + if isinstance(u, np.ndarray): + res = np.zeros(u.shape) res[abs(u) <= 1] = 0.5 return res if abs(u) <= 1: diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 8a32816f1..08db349cb 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -1,6 +1,5 @@ - import scipy.integrate -import numpy +import numpy as np from ..representation import FData @@ -35,14 +34,14 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None): raise ValueError("Objects should have the same dimensions") # Case different domain ranges - elif not numpy.array_equal(fdata1.domain_range, fdata2.domain_range): + elif not np.array_equal(fdata1.domain_range, fdata2.domain_range): raise ValueError("Domain ranges for both objects must be equal") # Case new evaluation points specified elif eval_points is not None: - if not numpy.array_equal(eval_points, fdata1.sample_points[0]): + if not np.array_equal(eval_points, fdata1.sample_points[0]): fdata1 = fdata1.to_grid(eval_points) - if not numpy.array_equal(eval_points, fdata2.sample_points[0]): + if not np.array_equal(eval_points, fdata2.sample_points[0]): fdata2 = fdata2.to_grid(eval_points) elif not isinstance(fdata1, FDataGrid) and isinstance(fdata2, FDataGrid): @@ -55,7 +54,7 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None): fdata1 = fdata1.to_grid(eval_points) fdata2 = fdata2.to_grid(eval_points) - elif not numpy.array_equal(fdata1.sample_points, + elif not np.array_equal(fdata1.sample_points, fdata2.sample_points): raise ValueError("Sample points for both objects must be equal or" "a new list evaluation points must be specified") @@ -111,7 +110,7 @@ def vectorial_norm(fdatagrid, p=2): 1 """ - data_matrix = numpy.linalg.norm(fdatagrid.data_matrix, ord=p, axis=-1, + data_matrix = np.linalg.norm(fdatagrid.data_matrix, ord=p, axis=-1, keepdims=True) return fdatagrid.copy(data_matrix=data_matrix) @@ -143,7 +142,7 @@ def distance_from_norm(norm, **kwargs): Firstly we create the functional data. - >>> x = numpy.linspace(0, 1, 1001) + >>> x = np.linspace(0, 1, 1001) >>> fd = FDataGrid([x], x) >>> fd2 = FDataGrid([x/2], x) @@ -190,11 +189,11 @@ def pairwise_distance(distance, **kwargs): def pairwise(fdata1, fdata2): # Checks - if not numpy.array_equal(fdata1.domain_range, fdata2.domain_range): + if not np.array_equal(fdata1.domain_range, fdata2.domain_range): raise ValueError("Domain ranges for both objects must be equal") # Creates an empty matrix with the desired size to store the results. - matrix = numpy.empty((fdata1.nsamples, fdata2.nsamples)) + matrix = np.empty((fdata1.nsamples, fdata2.nsamples)) # Iterates over the different samples of both objects. for i in range(fdata1.nsamples): @@ -260,8 +259,8 @@ def norm_lp(fdatagrid, p=2, p2=2): and y = x defined in the interval [0,1]. - >>> x = numpy.linspace(0,1,1001) - >>> fd = FDataGrid([numpy.ones(len(x)), x] ,x) + >>> x = np.linspace(0,1,1001) + >>> fd = FDataGrid([np.ones(len(x)), x] ,x) >>> norm_lp(fd).round(2) array([ 1. , 0.58]) @@ -278,10 +277,10 @@ def norm_lp(fdatagrid, p=2, p2=2): raise ValueError(f"p must be equal or greater than 1.") if fdatagrid.ndim_image > 1: - data_matrix = numpy.linalg.norm(fdatagrid.data_matrix, ord=p2, axis=-1, + data_matrix = np.linalg.norm(fdatagrid.data_matrix, ord=p2, axis=-1, keepdims=True) else: - data_matrix = numpy.abs(fdatagrid.data_matrix) + data_matrix = np.abs(fdatagrid.data_matrix) if fdatagrid.ndim_domain == 1: @@ -319,9 +318,9 @@ def lp_distance(fdata1, fdata2, p=2, *, eval_points=None): = 0 and y = x/2. The result then is an array 2x2 with the computed l2 distance between every pair of functions. - >>> x = numpy.linspace(0, 1, 1001) - >>> fd = FDataGrid([numpy.ones(len(x))], x) - >>> fd2 = FDataGrid([numpy.zeros(len(x))], x) + >>> x = np.linspace(0, 1, 1001) + >>> fd = FDataGrid([np.ones(len(x))], x) + >>> fd2 = FDataGrid([np.zeros(len(x))], x) >>> lp_distance(fd, fd2).round(2) 1.0 @@ -329,8 +328,8 @@ def lp_distance(fdata1, fdata2, p=2, *, eval_points=None): If the functional data are defined over a different set of points of discretisation the functions returns an exception. - >>> x = numpy.linspace(0, 2, 1001) - >>> fd2 = FDataGrid([numpy.zeros(len(x)), x/2 + 0.5], x) + >>> x = np.linspace(0, 2, 1001) + >>> fd2 = FDataGrid([np.zeros(len(x)), x/2 + 0.5], x) >>> lp_distance(fd, fd2) Traceback (most recent call last): .... @@ -479,12 +478,12 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): # L2 norm || sqrt(Dh) - 1 ||^2 penalty = warping(eval_points_normalized, derivative=1, keepdims=False)[0] - penalty = numpy.sqrt(penalty, out=penalty) + penalty = np.sqrt(penalty, out=penalty) penalty -= 1 - penalty = numpy.square(penalty, out=penalty) + penalty = np.square(penalty, out=penalty) penalty = scipy.integrate.simps(penalty, x=eval_points_normalized) - distance = numpy.sqrt(distance**2 + lam*penalty) + distance = np.sqrt(distance**2 + lam*penalty) return distance @@ -547,11 +546,11 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): derivative_warping = warping(eval_points_normalized, keepdims=False, derivative=1)[0] - derivative_warping = numpy.sqrt(derivative_warping, out=derivative_warping) + derivative_warping = np.sqrt(derivative_warping, out=derivative_warping) d = scipy.integrate.simps(derivative_warping, x=eval_points_normalized) - return numpy.arccos(d) + return np.arccos(d) def warping_distance(warping1, warping2, *, eval_points=None): @@ -601,10 +600,10 @@ def warping_distance(warping1, warping2, *, eval_points=None): warping2_data = warping2.derivative().data_matrix[0, ..., 0] # In this case the srsf is the sqrt(gamma') - srsf_warping1 = numpy.sqrt(warping1_data, out=warping1_data) - srsf_warping2 = numpy.sqrt(warping2_data, out=warping2_data) + srsf_warping1 = np.sqrt(warping1_data, out=warping1_data) + srsf_warping2 = np.sqrt(warping2_data, out=warping2_data) - product = numpy.multiply(srsf_warping1, srsf_warping2, out=srsf_warping1) + product = np.multiply(srsf_warping1, srsf_warping2, out=srsf_warping1) d = scipy.integrate.simps(product, x=warping1.sample_points[0]) - return numpy.arccos(d) + return np.arccos(d) diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index 8e18290c3..a43eb61db 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -3,7 +3,7 @@ This module contains methods to perform the landmark registration. """ -import numpy +import numpy as np from ... import FDataGrid from ...representation.interpolation import SplineInterpolator @@ -72,16 +72,16 @@ def landmark_shift_deltas(fd, landmarks, location=None): raise ValueError(f"landmark list ({len(landmarks)}) must have the same " f"length than the number of samples ({fd.nsamples})") - landmarks = numpy.atleast_1d(landmarks) + landmarks = np.atleast_1d(landmarks) # Parses location if location is None: - p = (numpy.max(landmarks, axis=0) + numpy.min(landmarks, axis=0)) / 2. + p = (np.max(landmarks, axis=0) + np.min(landmarks, axis=0)) / 2. elif callable(location): p = location(landmarks) else: try: - p = numpy.atleast_1d(location) + p = np.atleast_1d(location) except: raise ValueError("Invalid location, must be None, a callable or a " "number in the domain") @@ -224,11 +224,11 @@ def landmark_registration_warping(fd, landmarks, *, location=None, raise ValueError("The number of list of landmarks should be equal to " "the number of samples") - landmarks = numpy.asarray(landmarks).reshape((fd.nsamples, -1)) + landmarks = np.asarray(landmarks).reshape((fd.nsamples, -1)) n_landmarks = landmarks.shape[-1] - data_matrix = numpy.empty((fd.nsamples, n_landmarks + 2)) + data_matrix = np.empty((fd.nsamples, n_landmarks + 2)) data_matrix[:, 0] = fd.domain_range[0][0] data_matrix[:, -1] = fd.domain_range[0][1] @@ -236,7 +236,7 @@ def landmark_registration_warping(fd, landmarks, *, location=None, data_matrix[:, 1:-1] = landmarks if location is None: - sample_points = numpy.mean(data_matrix, axis=0) + sample_points = np.mean(data_matrix, axis=0) elif n_landmarks != len(location): @@ -244,7 +244,7 @@ def landmark_registration_warping(fd, landmarks, *, location=None, f"the number of landmarks ({len(location)}) != " f"({n_landmarks})") else: - sample_points = numpy.empty(n_landmarks + 2) + sample_points = np.empty(n_landmarks + 2) sample_points[0] = fd.domain_range[0][0] sample_points[-1] = fd.domain_range[0][1] sample_points[1:-1] = location @@ -259,7 +259,7 @@ def landmark_registration_warping(fd, landmarks, *, location=None, try: warping_points = fd.sample_points except AttributeError: - warping_points = [numpy.linspace(*domain, 201) + warping_points = [np.linspace(*domain, 201) for domain in fd.domain_range] return warping.to_grid(warping_points) diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index feb24988b..ba9762135 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -4,7 +4,7 @@ """ import collections -import numpy +import numpy as np import scipy.integrate from scipy.interpolate import PchipInterpolator @@ -152,20 +152,20 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, except AttributeError: nfine = max(registered_fdata.basis.nbasis * 10 + 1, 201) domain_range = registered_fdata.domain_range[0] - eval_points = numpy.linspace(*domain_range, nfine) + eval_points = np.linspace(*domain_range, nfine) else: - eval_points = numpy.asarray(eval_points) + eval_points = np.asarray(eval_points) x_fine = original_fdata.evaluate(eval_points, keepdims=False) y_fine = registered_fdata.evaluate(eval_points, keepdims=False) mu_fine = x_fine.mean(axis=0) # Mean unregistered function eta_fine = y_fine.mean(axis=0) # Mean registered function - mu_fine_sq = numpy.square(mu_fine) - eta_fine_sq = numpy.square(eta_fine) + mu_fine_sq = np.square(mu_fine) + eta_fine_sq = np.square(eta_fine) # Total mean square error of the original funtions # mse_total = scipy.integrate.simps( - # numpy.mean(numpy.square(x_fine - mu_fine), axis=0), + # np.mean(np.square(x_fine - mu_fine), axis=0), # eval_points) cr = 1. # Constant related to the covariation between the deformation @@ -179,13 +179,13 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, dh_fine_mean = dh_fine.mean(axis=0) dh_fine_center = dh_fine - dh_fine_mean - y_fine_sq = numpy.square(y_fine) # y^2 - y_fine_sq_center = numpy.subtract( + y_fine_sq = np.square(y_fine) # y^2 + y_fine_sq_center = np.subtract( y_fine_sq, eta_fine_sq) # y^2 - E[y^2] - covariate = numpy.inner(dh_fine_center.T, y_fine_sq_center.T) + covariate = np.inner(dh_fine_center.T, y_fine_sq_center.T) covariate = covariate.mean(axis=0) - cr += numpy.divide(scipy.integrate.simps(covariate, + cr += np.divide(scipy.integrate.simps(covariate, eval_points), scipy.integrate.simps(eta_fine_sq, eval_points)) @@ -195,8 +195,8 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, # mse due to amplitude variation # mse_amp = mse_total - mse_pha - y_fine_center = numpy.subtract(y_fine, eta_fine) - y_fine_center_sq = numpy.square(y_fine_center, out=y_fine_center) + y_fine_center = np.subtract(y_fine, eta_fine) + y_fine_center_sq = np.square(y_fine_center, out=y_fine_center) y_fine_center_sq_mean = y_fine_center_sq.mean(axis=0) mse_amp = scipy.integrate.simps(y_fine_center_sq_mean, eval_points) @@ -273,7 +273,7 @@ def invert_warping(fdatagrid, *, eval_points=None): y = fdatagrid(eval_points, keepdims=False) - data_matrix = numpy.empty((fdatagrid.nsamples, len(eval_points))) + data_matrix = np.empty((fdatagrid.nsamples, len(eval_points))) for i in range(fdatagrid.nsamples): data_matrix[i] = PchipInterpolator(y[i], eval_points)(eval_points) diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 20121b9a3..c9e22fb0d 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -4,7 +4,7 @@ functional data using shifts, in basis as well in discretized form. """ -import numpy +import numpy as np import scipy.integrate __author__ = "Pablo Marcos Manchón" @@ -100,14 +100,14 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, domain_range = fd.domain_range[0] if initial is None: - delta = numpy.zeros(fd.nsamples) + delta = np.zeros(fd.nsamples) elif len(initial) != fd.nsamples: raise ValueError(f"the initial shift ({len(initial)}) must have the " f"same length than the number of samples " f"({fd.nsamples})") else: - delta = numpy.asarray(initial) + delta = np.asarray(initial) # Fine equispaced mesh to evaluate the samples if eval_points is None: @@ -117,22 +117,22 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, nfine = len(eval_points) except AttributeError: nfine = max(fd.nbasis*10+1, 201) - eval_points = numpy.linspace(*domain_range, nfine) + eval_points = np.linspace(*domain_range, nfine) else: nfine = len(eval_points) - eval_points = numpy.asarray(eval_points) + eval_points = np.asarray(eval_points) # Auxiliar arrays to avoid multiple memory allocations - delta_aux = numpy.empty(fd.nsamples) - tfine_aux = numpy.empty(nfine) + delta_aux = np.empty(fd.nsamples) + tfine_aux = np.empty(nfine) # Computes the derivate of originals curves in the mesh points D1x = fd.evaluate(eval_points, derivative=1, keepdims=False) # Second term of the second derivate estimation of REGSSE. The # first term has been dropped to improve convergence (see references) - d2_regsse = scipy.integrate.trapz(numpy.square(D1x), eval_points, + d2_regsse = scipy.integrate.trapz(np.square(D1x), eval_points, axis=1) max_diff = tol + 1 @@ -143,10 +143,10 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, D1x_tmp = D1x tfine_tmp = eval_points tfine_aux_tmp = tfine_aux - domain = numpy.empty(nfine, dtype=numpy.dtype(bool)) + domain = np.empty(nfine, dtype=np.dtype(bool)) - ones = numpy.ones(fd.nsamples) - eval_points_rep = numpy.outer(ones, eval_points) + ones = np.ones(fd.nsamples) + eval_points_rep = np.outer(ones, eval_points) # Newton-Rhapson iteration while max_diff > tol and iter < maxiter: @@ -154,23 +154,23 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, # Updates the limits for non periodic functions ignoring the ends if restrict_domain: # Calculates the new limits - a = domain_range[0] - min(numpy.min(delta), 0) - b = domain_range[1] - max(numpy.max(delta), 0) + a = domain_range[0] - min(np.min(delta), 0) + b = domain_range[1] - max(np.max(delta), 0) # New interval is (a,b) - numpy.logical_and(tfine_tmp >= a, tfine_tmp <= b, out=domain) + np.logical_and(tfine_tmp >= a, tfine_tmp <= b, out=domain) eval_points = tfine_tmp[domain] tfine_aux = tfine_aux_tmp[domain] D1x = D1x_tmp[:, domain] # Reescale the second derivate could be other approach # d2_regsse = # d2_regsse_original * ( 1 + (a - b) / (domain[1] - domain[0])) - d2_regsse = scipy.integrate.trapz(numpy.square(D1x), + d2_regsse = scipy.integrate.trapz(np.square(D1x), eval_points, axis=1) - eval_points_rep = numpy.outer(ones, eval_points) + eval_points_rep = np.outer(ones, eval_points) # Computes the new values shifted - x = fd.evaluate(eval_points_rep + numpy.atleast_2d(delta).T, + x = fd.evaluate(eval_points_rep + np.atleast_2d(delta).T, aligned_evaluation=False, extrapolation=extrapolation, keepdims=False) @@ -178,18 +178,18 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, x.mean(axis=0, out=tfine_aux) # Calculates x - mean - numpy.subtract(x, tfine_aux, out=x) + np.subtract(x, tfine_aux, out=x) - d1_regsse = scipy.integrate.trapz(numpy.multiply(x, D1x, out=x), + d1_regsse = scipy.integrate.trapz(np.multiply(x, D1x, out=x), eval_points, axis=1) # Updates the shifts by the Newton-Rhapson iteration # delta = delta - step_size * d1_regsse / d2_regsse - numpy.divide(d1_regsse, d2_regsse, out=delta_aux) - numpy.multiply(delta_aux, step_size, out=delta_aux) - numpy.subtract(delta, delta_aux, out=delta) + np.divide(d1_regsse, d2_regsse, out=delta_aux) + np.multiply(delta_aux, step_size, out=delta_aux) + np.subtract(delta, delta_aux, out=delta) # Updates convergence criterions - max_diff = numpy.abs(delta_aux, out=delta_aux).max() + max_diff = np.abs(delta_aux, out=delta_aux).max() iter += 1 return delta diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 15520fd8b..b9f656ce1 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -11,7 +11,7 @@ """ import math -import numpy +import numpy as np from ...misc import kernels @@ -47,13 +47,13 @@ def nw(argvals, h=None, kernel=kernels.normal, w=None, cv=False): Defaults to False. Examples: - >>> nw(numpy.array([1,2,4,5,7]), 3.5).round(3) + >>> nw(np.array([1,2,4,5,7]), 3.5).round(3) array([[ 0.294, 0.282, 0.204, 0.153, 0.068], [ 0.249, 0.259, 0.22 , 0.179, 0.093], [ 0.165, 0.202, 0.238, 0.229, 0.165], [ 0.129, 0.172, 0.239, 0.249, 0.211], [ 0.073, 0.115, 0.221, 0.271, 0.319]]) - >>> nw(numpy.array([1,2,4,5,7]), 2).round(3) + >>> nw(np.array([1,2,4,5,7]), 2).round(3) array([[ 0.425, 0.375, 0.138, 0.058, 0.005], [ 0.309, 0.35 , 0.212, 0.114, 0.015], [ 0.103, 0.193, 0.319, 0.281, 0.103], @@ -64,16 +64,16 @@ def nw(argvals, h=None, kernel=kernels.normal, w=None, cv=False): ndarray: Smoothing matrix :math:`\hat{H}`. """ - delta_x = numpy.abs(numpy.subtract.outer(argvals, argvals)) + delta_x = np.abs(np.subtract.outer(argvals, argvals)) if h is None: - h = numpy.percentile(delta_x, 15) + h = np.percentile(delta_x, 15) if cv: - numpy.fill_diagonal(delta_x, math.inf) + np.fill_diagonal(delta_x, math.inf) delta_x = delta_x / h k = kernel(delta_x) if w is not None: k = k * w - rs = numpy.sum(k, 1) + rs = np.sum(k, 1) rs[rs == 0] = 1 return (k.T / rs).T @@ -111,13 +111,13 @@ def local_linear_regression(argvals, h, kernel=kernels.normal, w=None, Defaults to False. Examples: - >>> local_linear_regression(numpy.array([1,2,4,5,7]), 3.5).round(3) + >>> local_linear_regression(np.array([1,2,4,5,7]), 3.5).round(3) array([[ 0.614, 0.429, 0.077, -0.03 , -0.09 ], [ 0.381, 0.595, 0.168, -0. , -0.143], [-0.104, 0.112, 0.697, 0.398, -0.104], [-0.147, -0.036, 0.392, 0.639, 0.152], [-0.095, -0.079, 0.117, 0.308, 0.75 ]]) - >>> local_linear_regression(numpy.array([1,2,4,5,7]), 2).round(3) + >>> local_linear_regression(np.array([1,2,4,5,7]), 2).round(3) array([[ 0.714, 0.386, -0.037, -0.053, -0.01 ], [ 0.352, 0.724, 0.045, -0.081, -0.04 ], [-0.078, 0.052, 0.74 , 0.364, -0.078], @@ -129,18 +129,18 @@ def local_linear_regression(argvals, h, kernel=kernels.normal, w=None, ndarray: Smoothing matrix :math:`\hat{H}`. """ - delta_x = numpy.abs(numpy.subtract.outer(argvals, argvals)) # x_i - x_j + delta_x = np.abs(np.subtract.outer(argvals, argvals)) # x_i - x_j if cv: - numpy.fill_diagonal(delta_x, math.inf) + np.fill_diagonal(delta_x, math.inf) k = kernel(delta_x / h) # K(x_i - x/ h) - s1 = numpy.sum(k * delta_x, 1) # S_n_1 - s2 = numpy.sum(k * delta_x ** 2, 1) # S_n_2 + s1 = np.sum(k * delta_x, 1) # S_n_1 + s2 = np.sum(k * delta_x ** 2, 1) # S_n_2 b = (k * (s2 - delta_x * s1)).T # b_i(x_j) if cv: - numpy.fill_diagonal(b, 0) + np.fill_diagonal(b, 0) if w is not None: b = b * w - rs = numpy.sum(b, 1) # sum_{k=1}^{n}b_k(x_j) + rs = np.sum(b, 1) # sum_{k=1}^{n}b_k(x_j) return (b.T / rs).T # \\hat{H} @@ -169,7 +169,7 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): ndarray: Smoothing matrix. Examples: - >>> knn(numpy.array([1,2,4,5,7]), 2) + >>> knn(np.array([1,2,4,5,7]), 2) array([[ 0.5, 0.5, 0. , 0. , 0. ], [ 0.5, 0.5, 0. , 0. , 0. ], [ 0. , 0. , 0.5, 0.5, 0. ], @@ -178,7 +178,7 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): In case there are two points at the same distance it will take both. - >>> knn(numpy.array([1,2,3,5,7]), 2).round(3) + >>> knn(np.array([1,2,3,5,7]), 2).round(3) array([[ 0.5 , 0.5 , 0. , 0. , 0. ], [ 0.333, 0.333, 0.333, 0. , 0. ], [ 0. , 0.5 , 0.5 , 0. , 0. ], @@ -188,14 +188,14 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): """ # Distances matrix of points in argvals - delta_x = numpy.abs(numpy.subtract.outer(argvals, argvals)) + delta_x = np.abs(np.subtract.outer(argvals, argvals)) if k is None: - k = numpy.floor(numpy.percentile(range(1, len(argvals)), 5)) + k = np.floor(np.percentile(range(1, len(argvals)), 5)) elif k <= 0: raise ValueError('h must be greater than 0') if cv: - numpy.fill_diagonal(delta_x, math.inf) + np.fill_diagonal(delta_x, math.inf) # Tolerance to avoid points landing outside the kernel window due to # computation error @@ -203,7 +203,7 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): # For each row in the distances matrix, it calculates the furthest point # within the k nearest neighbours - vec = numpy.percentile(delta_x, k / len(argvals) * 100, axis=0, + vec = np.percentile(delta_x, k / len(argvals) * 100, axis=0, interpolation='lower') + tol rr = kernel((delta_x.T / vec).T) @@ -216,5 +216,5 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): rr = (rr.T * w).T # normalise every row - rs = numpy.sum(rr, 1) + rs = np.sum(rr, 1) return (rr.T / rs).T diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index bac812d9b..6018f1866 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -1,5 +1,5 @@ """Defines methods for the validation of the smoothing.""" -import numpy +import numpy as np from . import kernel_smoothers @@ -38,8 +38,8 @@ def cv(fdatagrid, s_matrix): """ y = fdatagrid.data_matrix[..., 0] - y_est = numpy.dot(s_matrix, y.T).T - return numpy.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) + y_est = np.dot(s_matrix, y.T).T + return np.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) def gcv(fdatagrid, s_matrix, penalisation_function=None): @@ -74,11 +74,11 @@ def gcv(fdatagrid, s_matrix, penalisation_function=None): """ y = fdatagrid.data_matrix[..., 0] - y_est = numpy.dot(s_matrix, y.T).T + y_est = np.dot(s_matrix, y.T).T if penalisation_function is not None: - return (numpy.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) + return (np.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) * penalisation_function(s_matrix)) - return (numpy.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) + return (np.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) * (1 - s_matrix.diagonal().mean()) ** -2) @@ -126,10 +126,10 @@ def minimise(fdatagrid, parameters, smoothing by means of the k-nearest neighbours method. >>> import skfda - >>> x = numpy.linspace(-2, 2, 5) + >>> x = np.linspace(-2, 2, 5) >>> fd = skfda.FDataGrid(x ** 2, x) >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn) - >>> numpy.array(res['scores']).round(2) + >>> np.array(res['scores']).round(2) array([ 11.67, 12.37]) >>> round(res['best_score'], 2) 11.67 @@ -157,23 +157,23 @@ def minimise(fdatagrid, parameters, >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, ... cv_method=cv) - >>> numpy.array(res['scores']).round(2) + >>> np.array(res['scores']).round(2) array([ 4.2, 5.5]) >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, ... penalisation_function=aic) - >>> numpy.array(res['scores']).round(2) + >>> np.array(res['scores']).round(2) array([ 9.35, 10.71]) >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, ... penalisation_function=fpe) - >>> numpy.array(res['scores']).round(2) + >>> np.array(res['scores']).round(2) array([ 9.8, 11. ]) >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, ... penalisation_function=shibata) - >>> numpy.array(res['scores']).round(2) + >>> np.array(res['scores']).round(2) array([ 7.56, 9.17]) >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, ... penalisation_function=rice) - >>> numpy.array(res['scores']).round(2) + >>> np.array(res['scores']).round(2) array([ 21. , 16.5]) """ @@ -201,13 +201,13 @@ def minimise(fdatagrid, parameters, scores.append( cv_method(fdatagrid, s)) # gets the best parameter. - h = parameters[int(numpy.argmin(scores))] + h = parameters[int(np.argmin(scores))] s = smoothing_method(sample_points, h, **kwargs) fdatagrid_adjusted = fdatagrid.copy( - data_matrix=numpy.dot(fdatagrid.data_matrix[..., 0], s.T)) + data_matrix=np.dot(fdatagrid.data_matrix[..., 0], s.T)) return {'scores': scores, - 'best_score': numpy.min(scores), + 'best_score': np.min(scores), 'best_parameter': h, 'hat_matrix': s, 'fdatagrid': fdatagrid_adjusted, @@ -228,7 +228,7 @@ def aic(s_matrix): float: Penalisation given by the Akaike's information criterion. """ - return numpy.exp(2 * s_matrix.diagonal().mean()) + return np.exp(2 * s_matrix.diagonal().mean()) def fpe(s_matrix): diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index d863d28b5..9e37ea36b 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -8,7 +8,7 @@ import copy from abc import ABC, abstractmethod -import numpy +import numpy as np import scipy.integrate import scipy.interpolate import scipy.linalg @@ -48,7 +48,7 @@ def _check_domain(domain_range): def _same_domain(one_domain_range, other_domain_range): - return numpy.array_equal(one_domain_range, other_domain_range) + return np.array_equal(one_domain_range, other_domain_range) class Basis(ABC): @@ -138,8 +138,8 @@ def evaluate(self, eval_points, derivative=0): eval_points. """ - eval_points = numpy.asarray(eval_points) - if numpy.any(numpy.isnan(eval_points)): + eval_points = np.asarray(eval_points) + if np.any(np.isnan(eval_points)): raise ValueError("The list of points where the function is " "evaluated can not contain nan values.") @@ -190,7 +190,7 @@ def _evaluate_single_basis_coefficients(self, coefficients, basis_index, x, """ if x not in cache: - res = numpy.zeros(self.nbasis) + res = np.zeros(self.nbasis) for i, k in enumerate(coefficients): if callable(k): res += k(x) * self._compute_matrix([x], i)[:, 0] @@ -214,7 +214,7 @@ def _numerical_penalty(self, coefficients): # Range of first dimension domain_range = self.domain_range[0] - penalty_matrix = numpy.zeros((self.nbasis, self.nbasis)) + penalty_matrix = np.zeros((self.nbasis, self.nbasis)) cache = {} for i in range(self.nbasis): penalty_matrix[i, i] = scipy.integrate.quad( @@ -315,7 +315,7 @@ def copy(self): return copy.deepcopy(self) def to_basis(self): - return FDataBasis(self.copy(), numpy.identity(self.nbasis)) + return FDataBasis(self.copy(), np.identity(self.nbasis)) def _list_to_R(self, knots): retstring = "c(" @@ -327,7 +327,7 @@ def _to_R(self): raise NotImplementedError def inner_product(self, other): - return numpy.transpose(other.inner_product(self.to_basis())) + return np.transpose(other.inner_product(self.to_basis())) def __repr__(self): """Representation of a Basis object.""" @@ -383,7 +383,7 @@ def _ndegenerated(self, penalty_degree): def _derivative(self, coefs, order=1): return (self.copy(), coefs.copy() if order == 0 - else self.copy(), numpy.zeros(coefs.shape)) + else self.copy(), np.zeros(coefs.shape)) def _compute_matrix(self, eval_points, derivative=0): """Compute the basis or its derivatives given a list of values. @@ -402,7 +402,7 @@ def _compute_matrix(self, eval_points, derivative=0): eval_points. """ - return numpy.ones((1, len(eval_points))) if derivative == 0 else numpy.zeros((1, len(eval_points))) + return np.ones((1, len(eval_points))) if derivative == 0 else np.zeros((1, len(eval_points))) def penalty(self, derivative_degree=None, coefficients=None): r"""Return a penalty matrix given a differential operator. @@ -447,8 +447,8 @@ def penalty(self, derivative_degree=None, coefficients=None): if derivative_degree is None: return self._numerical_penalty(coefficients) - return (numpy.full((1, 1), (self.domain_range[0][1] - self.domain_range[0][0])) - if derivative_degree == 0 else numpy.zeros((1, 1))) + return (np.full((1, 1), (self.domain_range[0][1] - self.domain_range[0][0])) + if derivative_degree == 0 else np.zeros((1, 1))) def basis_of_product(self, other): """Multiplication of a Constant Basis with other Basis""" @@ -538,7 +538,7 @@ def _compute_matrix(self, eval_points, derivative=0): """ # Initialise empty matrix - mat = numpy.zeros((self.nbasis, len(eval_points))) + mat = np.zeros((self.nbasis, len(eval_points))) # For each basis computes its value for each evaluation if derivative == 0: @@ -556,7 +556,7 @@ def _compute_matrix(self, eval_points, derivative=0): def _derivative(self, coefs, order=1): return (Monomial(self.domain_range, self.nbasis - order), - numpy.array([numpy.polyder(x[::-1], order)[::-1] + np.array([np.polyder(x[::-1], order)[::-1] for x in coefs])) def penalty(self, derivative_degree=None, coefficients=None): @@ -607,7 +607,7 @@ def penalty(self, derivative_degree=None, coefficients=None): integration_domain = self.domain_range[0] # initialize penalty matrix as all zeros - penalty_matrix = numpy.zeros((self.nbasis, self.nbasis)) + penalty_matrix = np.zeros((self.nbasis, self.nbasis)) # iterate over the cartesion product of the basis system with itself for ibasis in range(self.nbasis): # notice that the index ibasis it is also the exponent of the @@ -767,7 +767,7 @@ def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): "number of basis.") if domain_range is None: domain_range = (0, 1) - knots = list(numpy.linspace(*domain_range, nbasis - order + 2)) + knots = list(np.linspace(*domain_range, nbasis - order + 2)) else: knots = list(knots) knots.sort() @@ -838,13 +838,13 @@ def _compute_matrix(self, eval_points, derivative=0): """ # Places m knots at the boundaries - knots = numpy.array([self.knots[0]] * (self.order - 1) + self.knots + knots = np.array([self.knots[0]] * (self.order - 1) + self.knots + [self.knots[-1]] * (self.order - 1)) # c is used the select which spline the function splev below computes - c = numpy.zeros(len(knots)) + c = np.zeros(len(knots)) # Initialise empty matrix - mat = numpy.empty((self.nbasis, len(eval_points))) + mat = np.empty((self.nbasis, len(eval_points))) # For each basis computes its value for each evaluation point for i in range(self.nbasis): @@ -868,7 +868,7 @@ def _derivative(self, coefs, order=1): deriv_basis = BSpline._from_scipy_BSpline(deriv_splines[0])[0] - return deriv_basis, numpy.array(deriv_coefs)[:, 0:deriv_basis.nbasis] + return deriv_basis, np.array(deriv_coefs)[:, 0:deriv_basis.nbasis] def penalty(self, derivative_degree=None, coefficients=None): r"""Return a penalty matrix given a differential operator. @@ -911,30 +911,30 @@ def penalty(self, derivative_degree=None, coefficients=None): if derivative_degree == self.order - 1: # The derivative of the bsplines are constant in the intervals # defined between knots - knots = numpy.array(self.knots) + knots = np.array(self.knots) mid_inter = (knots[1:] + knots[:-1]) / 2 constants = self.evaluate(mid_inter, derivative=derivative_degree).T - knots_intervals = numpy.diff(self.knots) + knots_intervals = np.diff(self.knots) # Integration of product of constants - return constants.T @ numpy.diag(knots_intervals) @ constants + return constants.T @ np.diag(knots_intervals) @ constants - if numpy.all(numpy.diff(self.knots) != 0): + if np.all(np.diff(self.knots) != 0): # Compute exactly using the piecewise polynomial # representation of splines # Places m knots at the boundaries - knots = numpy.array( + knots = np.array( [self.knots[0]] * (self.order - 1) + self.knots + [self.knots[-1]] * (self.order - 1)) # c is used the select which spline the function # PPoly.from_spline below computes - c = numpy.zeros(len(knots)) + c = np.zeros(len(knots)) # Initialise empty list to store the piecewise polynomials ppoly_lst = [] - no_0_intervals = numpy.where(numpy.diff(knots) > 0)[0] + no_0_intervals = np.where(np.diff(knots) > 0)[0] # For each basis gets its piecewise polynomial representation for i in range(self.nbasis): @@ -956,7 +956,7 @@ def penalty(self, derivative_degree=None, coefficients=None): coeffs[j + 1:] += ( (binom(self.order - j - 1, range(1, self.order - j)) - * numpy.vstack([(-a) ** numpy.array( + * np.vstack([(-a) ** np.array( range(1, self.order - j)) for a in self.knots[:-1]]) ).T * pp[j]) @@ -965,10 +965,10 @@ def penalty(self, derivative_degree=None, coefficients=None): # Now for each pair of basis computes the inner product after # applying the linear differential operator - penalty_matrix = numpy.zeros((self.nbasis, self.nbasis)) + penalty_matrix = np.zeros((self.nbasis, self.nbasis)) for interval in range(len(no_0_intervals)): for i in range(self.nbasis): - poly_i = numpy.trim_zeros(ppoly_lst[i][:, + poly_i = np.trim_zeros(ppoly_lst[i][:, interval], 'f') if len(poly_i) <= derivative_degree: # if the order of the polynomial is lesser or @@ -979,11 +979,11 @@ def penalty(self, derivative_degree=None, coefficients=None): integral = polyint(_polypow(polyder( poly_i, derivative_degree), 2)) # definite integral - penalty_matrix[i, i] += numpy.diff(polyval( + penalty_matrix[i, i] += np.diff(polyval( integral, self.knots[interval: interval + 2]))[0] for j in range(i + 1, self.nbasis): - poly_j = numpy.trim_zeros(ppoly_lst[j][:, + poly_j = np.trim_zeros(ppoly_lst[j][:, interval], 'f') if len(poly_j) <= derivative_degree: # if the order of the polynomial is lesser @@ -995,7 +995,7 @@ def penalty(self, derivative_degree=None, coefficients=None): polymul(polyder(poly_i, derivative_degree), polyder(poly_j, derivative_degree))) # definite integral - penalty_matrix[i, j] += numpy.diff(polyval( + penalty_matrix[i, j] += np.diff(polyval( integral, self.knots[interval: interval + 2]) )[0] penalty_matrix[j, i] = penalty_matrix[i, j] @@ -1022,7 +1022,7 @@ def rescale(self, domain_range=None): original basis. """ - knots = numpy.array(self.knots, dtype=numpy.dtype('float')) + knots = np.array(self.knots, dtype=np.dtype('float')) if domain_range is not None: # Rescales the knots knots -= knots[0] @@ -1061,16 +1061,16 @@ def basis_of_product(self, other): return other.rbasis_of_product(self) if isinstance(other, BSpline): - uniqueknots = numpy.union1d(self.inknots, other.inknots) + uniqueknots = np.union1d(self.inknots, other.inknots) - multunique = numpy.zeros(len(uniqueknots), dtype=numpy.int32) + multunique = np.zeros(len(uniqueknots), dtype=np.int32) for i in range(len(uniqueknots)): - mult1 = numpy.count_nonzero(self.inknots == uniqueknots[i]) - mult2 = numpy.count_nonzero(other.inknots == uniqueknots[i]) + mult1 = np.count_nonzero(self.inknots == uniqueknots[i]) + mult2 = np.count_nonzero(other.inknots == uniqueknots[i]) multunique[i] = max(mult1, mult2) m2 = 0 - allknots = numpy.zeros(numpy.sum(multunique)) + allknots = np.zeros(np.sum(multunique)) for i in range(len(uniqueknots)): m1 = m2 m2 = m2 + multunique[i] @@ -1080,7 +1080,7 @@ def basis_of_product(self, other): norder2 = other.nbasis - len(other.inknots) norder = min(norder1 + norder2 - 1, 20) - allbreaks = [self.domain_range[0][0]] + numpy.ndarray.tolist(allknots) + [self.domain_range[0][1]] + allbreaks = [self.domain_range[0][0]] + np.ndarray.tolist(allknots) + [self.domain_range[0][1]] nbasis = len(allbreaks) + norder - 2 return BSpline(self.domain_range, nbasis, norder, allbreaks) else: @@ -1104,10 +1104,10 @@ def _to_R(self): def _to_scipy_BSpline(self, coefs): - knots = numpy.concatenate(( - numpy.repeat(self.knots[0], self.order - 1), + knots = np.concatenate(( + np.repeat(self.knots[0], self.order - 1), self.knots, - numpy.repeat(self.knots[-1], self.order - 1))) + np.repeat(self.knots[-1], self.order - 1))) return SciBSpline(knots, coefs, self.order - 1) @@ -1155,15 +1155,15 @@ class Fourier(Basis): Examples: Constructs specifying number of basis, definition interval and period. - >>> fb = Fourier((0, numpy.pi), nbasis=3, period=1) - >>> fb.evaluate([0, numpy.pi / 4, numpy.pi / 2, numpy.pi]).round(2) + >>> fb = Fourier((0, np.pi), nbasis=3, period=1) + >>> fb.evaluate([0, np.pi / 4, np.pi / 2, np.pi]).round(2) array([[ 1. , 1. , 1. , 1. ], [ 0. , -1.38, -0.61, 1.1 ], [ 1.41, 0.31, -1.28, 0.89]]) And evaluate second derivative - >>> fb.evaluate([0, numpy.pi / 4, numpy.pi / 2, numpy.pi], + >>> fb.evaluate([0, np.pi / 4, np.pi / 2, np.pi], ... derivative = 2).round(2) array([[ 0. , 0. , 0. , 0. ], [ -0. , 54.46, 24.02, -43.37], @@ -1219,52 +1219,52 @@ def _compute_matrix(self, eval_points, derivative=0): if derivative < 0: raise ValueError("derivative only takes non-negative values.") - omega = 2 * numpy.pi / self.period + omega = 2 * np.pi / self.period omega_t = omega * eval_points nbasis = self.nbasis if self.nbasis % 2 != 0 else self.nbasis + 1 # Initialise empty matrix - mat = numpy.empty((self.nbasis, len(eval_points))) + mat = np.empty((self.nbasis, len(eval_points))) if derivative == 0: # First base function is a constant # The division by numpy.sqrt(2) is so that it has the same norm as # the sine and cosine: sqrt(period / 2) - mat[0] = numpy.ones(len(eval_points)) / numpy.sqrt(2) + mat[0] = np.ones(len(eval_points)) / np.sqrt(2) if nbasis > 1: # 2*pi*n*x / period - args = numpy.outer(range(1, nbasis // 2 + 1), omega_t) + args = np.outer(range(1, nbasis // 2 + 1), omega_t) index = range(1, nbasis - 1, 2) # odd indexes are sine functions - mat[index] = numpy.sin(args) + mat[index] = np.sin(args) index = range(2, nbasis, 2) # even indexes are cosine functions - mat[index] = numpy.cos(args) + mat[index] = np.cos(args) # evaluates the derivatives else: # First base function is a constant, so its derivative is 0. - mat[0] = numpy.zeros(len(eval_points)) + mat[0] = np.zeros(len(eval_points)) if nbasis > 1: # (2*pi*n / period) ^ n_derivative - factor = numpy.outer( + factor = np.outer( (-1) ** (derivative // 2) - * (numpy.array(range(1, nbasis // 2 + 1)) * omega) + * (np.array(range(1, nbasis // 2 + 1)) * omega) ** derivative, - numpy.ones(len(eval_points))) + np.ones(len(eval_points))) # 2*pi*n*x / period - args = numpy.outer(range(1, nbasis // 2 + 1), omega_t) + args = np.outer(range(1, nbasis // 2 + 1), omega_t) # even indexes index_e = range(2, nbasis, 2) # odd indexes index_o = range(1, nbasis - 1, 2) if derivative % 2 == 0: - mat[index_o] = factor * numpy.sin(args) - mat[index_e] = factor * numpy.cos(args) + mat[index_o] = factor * np.sin(args) + mat[index_e] = factor * np.cos(args) else: - mat[index_o] = factor * numpy.cos(args) - mat[index_e] = -factor * numpy.sin(args) + mat[index_o] = factor * np.cos(args) + mat[index_e] = -factor * np.sin(args) # normalise - mat = mat / numpy.sqrt(self.period / 2) + mat = mat / np.sqrt(self.period / 2) return mat def _ndegenerated(self, penalty_degree): @@ -1282,10 +1282,10 @@ def _ndegenerated(self, penalty_degree): def _derivative(self, coefs, order=1): - omega = 2 * numpy.pi / self.period - deriv_factor = (numpy.arange(1, (self.nbasis+1)/2) * omega) ** order + omega = 2 * np.pi / self.period + deriv_factor = (np.arange(1, (self.nbasis+1)/2) * omega) ** order - deriv_coefs = numpy.zeros(coefs.shape) + deriv_coefs = np.zeros(coefs.shape) cos_sign, sin_sign = (-1)**int((order+1)/2), (-1)**int(order/2) @@ -1333,17 +1333,17 @@ def penalty(self, derivative_degree=None, coefficients=None): """ if isinstance(derivative_degree, int): - omega = 2 * numpy.pi / self.period + omega = 2 * np.pi / self.period # the derivatives of the functions of the basis are also orthogonal # so only the diagonal is different from 0. - penalty_matrix = numpy.zeros(self.nbasis) + penalty_matrix = np.zeros(self.nbasis) if derivative_degree == 0: penalty_matrix[0] = 1 else: # the derivative of a constant is 0 # the first basis function is a constant penalty_matrix[0] = 0 - index_even = numpy.array(range(2, self.nbasis, 2)) + index_even = np.array(range(2, self.nbasis, 2)) exponents = index_even / 2 # factor resulting of deriving the basis function the times # indcated in the derivative_degree @@ -1352,7 +1352,7 @@ def penalty(self, derivative_degree=None, coefficients=None): # integral is just the factor penalty_matrix[index_even - 1] = factor penalty_matrix[index_even] = factor - return numpy.diag(penalty_matrix) + return np.diag(penalty_matrix) else: # implement using inner product return self._numerical_penalty(coefficients) @@ -1456,7 +1456,7 @@ def __init__(self, basis, coefficients, *, dataset_label=None, have the same length or number of columns as the number of basis function in the basis. """ - coefficients = numpy.atleast_2d(coefficients) + coefficients = np.atleast_2d(coefficients) if coefficients.shape[1] != basis.nbasis: raise ValueError("The length or number of columns of coefficients " "has to be the same equal to the number of " @@ -1581,14 +1581,14 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, # k is the number of elements of the basis # Each sample in a column (m x n) - data_matrix = numpy.atleast_2d(data_matrix).T + data_matrix = np.atleast_2d(data_matrix).T # Each basis in a column basis_values = basis.evaluate(sample_points).T # If no weight matrix is given all the weights are one if not weight_matrix: - weight_matrix = numpy.identity(basis_values.shape[0]) + weight_matrix = np.identity(basis_values.shape[0]) # We need to solve the equation # (phi' W phi + lambda * R) C = phi' W Y @@ -1634,7 +1634,7 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, penalty_matrix = basis.penalty(penalty_degree, penalty_coefficients) - w, v = numpy.linalg.eigh(penalty_matrix) + w, v = np.linalg.eigh(penalty_matrix) # Reduction of the penalty matrix taking away 0 or almost # zeros eigenvalues ndegenerated = basis._ndegenerated(penalty_degree) @@ -1645,15 +1645,15 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, w = w[:index:-1] v = v[:, :index:-1] - penalty_matrix = v @ numpy.diag(numpy.sqrt(w)) + penalty_matrix = v @ np.diag(np.sqrt(w)) # Augment the basis matrix with the square root of the # penalty matrix - basis_values = numpy.concatenate([ + basis_values = np.concatenate([ basis_values, - numpy.sqrt(smoothness_parameter) * penalty_matrix.T], + np.sqrt(smoothness_parameter) * penalty_matrix.T], axis=0) # Augment data matrix by n - ndegenerated zeros - data_matrix = numpy.pad(data_matrix, + data_matrix = np.pad(data_matrix, ((0, len(v) - ndegenerated), (0, 0)), mode='constant') @@ -1663,11 +1663,11 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, # by means of the QR decomposition # B = Q @ R - q, r = numpy.linalg.qr(basis_values) + q, r = np.linalg.qr(basis_values) right_matrix = q.T @ data_matrix # R @ C = Q.T @ D - coefficients = numpy.linalg.solve(r, right_matrix) + coefficients = np.linalg.solve(r, right_matrix) # The ith column is the coefficients of the ith basis for each # sample coefficients = coefficients.T @@ -1678,7 +1678,7 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, elif data_matrix.shape[0] == basis.nbasis: # If the number of basis equals the number of points and no # smoothing is required - coefficients = numpy.linalg.solve(basis_values, data_matrix) + coefficients = np.linalg.solve(basis_values, data_matrix) else: # data_matrix.shape[0] < basis.nbasis raise ValueError(f"The number of basis functions ({basis.nbasis}) " @@ -1738,7 +1738,7 @@ def _evaluate(self, eval_points, *, derivative=0): # each row contains the values of one element of the basis basis_values = self.basis.evaluate(eval_points, derivative) - res = numpy.tensordot(self.coefficients, basis_values, axes=(1, 0)) + res = np.tensordot(self.coefficients, basis_values, axes=(1, 0)) return res.reshape((self.nsamples, len(eval_points), 1)) @@ -1769,15 +1769,15 @@ def _evaluate_composed(self, eval_points, *, derivative=0): eval_points = eval_points[..., 0] - res_matrix = numpy.empty((self.nsamples, eval_points.shape[1])) + res_matrix = np.empty((self.nsamples, eval_points.shape[1])) - _matrix = numpy.empty((eval_points.shape[1], self.nbasis)) + _matrix = np.empty((eval_points.shape[1], self.nbasis)) for i in range(self.nsamples): basis_values = self.basis.evaluate(eval_points[i], derivative).T - numpy.multiply(basis_values, self.coefficients[i], out=_matrix) - numpy.sum(_matrix, axis=1, out=res_matrix[i]) + np.multiply(basis_values, self.coefficients[i], out=_matrix) + np.sum(_matrix, axis=1, out=res_matrix[i]) return res_matrix.reshape((self.nsamples, eval_points.shape[1], 1)) @@ -1815,11 +1815,11 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, if eval_points is None: # Grid to discretize the function nfine = max(self.nbasis * 10 + 1, 201) - eval_points = numpy.linspace(*domain_range, nfine) + eval_points = np.linspace(*domain_range, nfine) else: - eval_points = numpy.asarray(eval_points) + eval_points = np.asarray(eval_points) - if numpy.isscalar(shifts): # Special case, all curves with same shift + if np.isscalar(shifts): # Special case, all curves with same shift _basis = self.basis.rescale((domain_range[0] + shifts, domain_range[1] + shifts)) @@ -1835,19 +1835,19 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, f"({self.nsamples})") if restrict_domain: - a = domain_range[0] - min(numpy.min(shifts), 0) - b = domain_range[1] - max(numpy.max(shifts), 0) + a = domain_range[0] - min(np.min(shifts), 0) + b = domain_range[1] - max(np.max(shifts), 0) domain = (a, b) eval_points = eval_points[ - numpy.logical_and(eval_points >= a, + np.logical_and(eval_points >= a, eval_points <= b)] else: domain = domain_range - points_shifted = numpy.outer(numpy.ones(self.nsamples), + points_shifted = np.outer(np.ones(self.nsamples), eval_points) - points_shifted += numpy.atleast_2d(shifts).T + points_shifted += np.atleast_2d(shifts).T # Matrix of shifted values _data_matrix = self.evaluate(points_shifted, @@ -1896,7 +1896,7 @@ def mean(self): ...) """ - return self.copy(coefficients=numpy.mean(self.coefficients, axis=0)) + return self.copy(coefficients=np.mean(self.coefficients, axis=0)) def gmean(self, eval_points=None): """Compute the geometric mean of the functional data object. @@ -1997,7 +1997,7 @@ def to_grid(self, eval_points=None): if eval_points is None: npoints = max(501, 10 * self.nbasis) - eval_points = numpy.linspace(*self.domain_range[0], npoints) + eval_points = np.linspace(*self.domain_range[0], npoints) return grid.FDataGrid(self.evaluate(eval_points, keepdims=False), sample_points=eval_points, @@ -2078,12 +2078,12 @@ def times(self, other): neval = max(10 * max(self.nbasis, other.nbasis) + 1, MIN_EVAL_SAMPLES) (left, right) = self.domain_range[0] - evalarg = numpy.linspace(left, right, neval) + evalarg = np.linspace(left, right, neval) - first = self.copy(coefficients=(numpy.repeat(self.coefficients, + first = self.copy(coefficients=(np.repeat(self.coefficients, other.nsamples, axis=0) if self.nsamples == 1 and other.nsamples > 1 else self.coefficients.copy())) - second = other.copy(coefficients=(numpy.repeat(other.coefficients, + second = other.copy(coefficients=(np.repeat(other.coefficients, self.nsamples, axis=0) if other.nsamples == 1 and self.nsamples > 1 else other.coefficients.copy())) @@ -2094,7 +2094,7 @@ def times(self, other): if isinstance(other, int): other = [other for _ in range(self.nsamples)] - coefs = numpy.transpose(numpy.atleast_2d(other)) + coefs = np.transpose(np.atleast_2d(other)) return self.copy(coefficients=self.coefficients*coefs) def inner_product(self, other, lfd_self=None, lfd_other=None, @@ -2148,7 +2148,7 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, if weights is not None: other = other.times(weights) - matrix = numpy.empty((self.nsamples, other.nsamples)) + matrix = np.empty((self.nsamples, other.nsamples)) (left, right) = self.domain_range[0] for i in range(self.nsamples): @@ -2171,7 +2171,7 @@ def _array_to_R(self, coefficients, transpose=False): return NotImplementedError if transpose is True: - coefficients = numpy.transpose(coefficients) + coefficients = np.transpose(coefficients) (rows, cols) = coefficients.shape retstring = "matrix(c(" @@ -2203,7 +2203,7 @@ def __eq__(self, other): """Equality of FDataBasis""" # TODO check all other params return (self.basis == other.basis - and numpy.all(self.coefficients == other.coefficients)) + and np.all(self.coefficients == other.coefficients)) def concatenate(self, other): """Join samples from a similar FDataBasis object. @@ -2222,7 +2222,7 @@ def concatenate(self, other): if other.basis != self.basis: raise ValueError("The objects should have the same basis.") - return self.copy(coefficients=numpy.concatenate((self.coefficients, + return self.copy(coefficients=np.concatenate((self.coefficients, other.coefficients), axis=0)) diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 9637ce52c..e8fa5e72c 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -9,7 +9,7 @@ import numbers import copy -import numpy +import numpy as np import scipy.stats.mstats @@ -69,7 +69,7 @@ class FDataGrid(FData): The number of columns of data_matrix have to be the length of sample_points. - >>> FDataGrid(numpy.array([1,2,4,5,8]), range(6)) + >>> FDataGrid(np.array([1,2,4,5,8]), range(6)) Traceback (most recent call last): .... ValueError: Incorrect dimension in data_matrix and sample_points... @@ -123,11 +123,11 @@ def __init__(self, data_matrix, sample_points=None, of the image. """ - self.data_matrix = numpy.atleast_2d(data_matrix) + self.data_matrix = np.atleast_2d(data_matrix) if sample_points is None: self.sample_points = _list_of_arrays( - [numpy.linspace(0, 1, self.data_matrix.shape[i]) for i in + [np.linspace(0, 1, self.data_matrix.shape[i]) for i in range(1, self.data_matrix.ndim)]) else: @@ -139,14 +139,14 @@ def __init__(self, data_matrix, sample_points=None, data_shape = self.data_matrix.shape[1: 1 + self.ndim_domain] sample_points_shape = [len(i) for i in self.sample_points] - if not numpy.array_equal(data_shape, sample_points_shape): + if not np.array_equal(data_shape, sample_points_shape): raise ValueError("Incorrect dimension in data_matrix and " "sample_points. Data has shape {} and sample " "points have shape {}" .format(data_shape, sample_points_shape)) - self._sample_range = numpy.array( + self._sample_range = np.array( [(self.sample_points[i][0], self.sample_points[i][-1]) for i in range(self.ndim_domain)]) @@ -155,7 +155,7 @@ def __init__(self, data_matrix, sample_points=None, # Default value for domain_range is a list of tuples with # the first and last element of each list ofthe sample_points. else: - self._domain_range = numpy.atleast_2d(domain_range) + self._domain_range = np.atleast_2d(domain_range) # sample range must by a 2 dimension matrix with as many rows as # dimensions in the domain and 2 columns if (self._domain_range.ndim != 2 or self._domain_range.shape[1] != 2 @@ -170,7 +170,7 @@ def __init__(self, data_matrix, sample_points=None, # Adjust the data matrix if the dimension of the image is one if self.data_matrix.ndim == 1 + self.ndim_domain: - self.data_matrix = self.data_matrix[..., numpy.newaxis] + self.data_matrix = self.data_matrix[..., np.newaxis] if axes_labels is not None and len(axes_labels) != (self.ndim_domain + self.ndim_image): raise ValueError("There must be a label for each of the" @@ -417,7 +417,7 @@ def derivative(self, order=1): if self.ndim_domain > 1 or self.ndim_image > 1: raise NotImplementedError("Not implemented for 2 or more" " dimensional data.") - if numpy.isnan(self.data_matrix).any(): + if np.isnan(self.data_matrix).any(): raise ValueError("The FDataGrid object cannot contain nan " "elements.") data_matrix = self.data_matrix[..., 0] @@ -425,14 +425,14 @@ def derivative(self, order=1): for _ in range(order): mdata = [] for i in range(self.nsamples): - arr = (numpy.diff(data_matrix[i]) / + arr = (np.diff(data_matrix[i]) / (sample_points[1:] - sample_points[:-1])) - arr = numpy.append(arr, arr[-1]) + arr = np.append(arr, arr[-1]) arr[1:-1] += arr[:-2] arr[1:-1] /= 2 mdata.append(arr) - data_matrix = numpy.array(mdata) + data_matrix = np.array(mdata) if self.dataset_label: dataset_label = "{} - {} derivative".format(self.dataset_label, @@ -446,7 +446,7 @@ def derivative(self, order=1): def __check_same_dimensions(self, other): if self.data_matrix.shape[1] != other.data_matrix.shape[1]: raise ValueError("Error in columns dimensions") - if not numpy.array_equal(self.sample_points, other.sample_points): + if not np.array_equal(self.sample_points, other.sample_points): raise ValueError("Sample points for both objects must be equal") def mean(self): @@ -468,7 +468,7 @@ def var(self): variance of all the samples in the original FDataGrid object. """ - return self.copy(data_matrix=[numpy.var(self.data_matrix, 0)]) + return self.copy(data_matrix=[np.var(self.data_matrix, 0)]) def cov(self): """Compute the covariance. @@ -486,8 +486,8 @@ def cov(self): else: dataset_label = None - return self.copy(data_matrix=numpy.cov(self.data_matrix, - rowvar=False)[numpy.newaxis, ...], + return self.copy(data_matrix=np.cov(self.data_matrix, + rowvar=False)[np.newaxis, ...], sample_points=[self.sample_points[0], self.sample_points[0]], domain_range=[self.domain_range[0], @@ -512,7 +512,7 @@ def __add__(self, other): It supports other FDataGrid objects, numpy.ndarray and numbers. """ - if isinstance(other, (numpy.ndarray, numbers.Number)): + if isinstance(other, (np.ndarray, numbers.Number)): data_matrix = other elif isinstance(other, FDataGrid): self.__check_same_dimensions(other) @@ -537,7 +537,7 @@ def __sub__(self, other): It supports other FDataGrid objects, numpy.ndarray and numbers. """ - if isinstance(other, (numpy.ndarray, numbers.Number)): + if isinstance(other, (np.ndarray, numbers.Number)): data_matrix = other elif isinstance(other, FDataGrid): self.__check_same_dimensions(other) @@ -553,7 +553,7 @@ def __rsub__(self, other): It supports other FDataGrid objects, numpy.ndarray and numbers. """ - if isinstance(other, (numpy.ndarray, numbers.Number)): + if isinstance(other, (np.ndarray, numbers.Number)): data_matrix = other elif isinstance(other, FDataGrid): self.__check_same_dimensions(other) @@ -569,7 +569,7 @@ def __mul__(self, other): It supports other FDataGrid objects, numpy.ndarray and numbers. """ - if isinstance(other, (numpy.ndarray, numbers.Number)): + if isinstance(other, (np.ndarray, numbers.Number)): data_matrix = other elif isinstance(other, FDataGrid): self.__check_same_dimensions(other) @@ -593,7 +593,7 @@ def __truediv__(self, other): It supports other FDataGrid objects, numpy.ndarray and numbers. """ - if isinstance(other, (numpy.ndarray, numbers.Number)): + if isinstance(other, (np.ndarray, numbers.Number)): data_matrix = other elif isinstance(other, FDataGrid): self.__check_same_dimensions(other) @@ -609,7 +609,7 @@ def __rtruediv__(self, other): It supports other FDataGrid objects, numpy.ndarray and numbers. """ - if isinstance(other, (numpy.ndarray, numbers.Number)): + if isinstance(other, (np.ndarray, numbers.Number)): data_matrix = other elif isinstance(other, FDataGrid): self.__check_same_dimensions(other) @@ -657,7 +657,7 @@ def concatenate(self, other): # Checks self.__check_same_dimensions(other) - return self.copy(data_matrix=numpy.concatenate((self.data_matrix, + return self.copy(data_matrix=np.concatenate((self.data_matrix, other.data_matrix), axis=0)) @@ -689,7 +689,7 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): else: X = self.sample_points[0] Y = self.sample_points[1] - X, Y = numpy.meshgrid(X, Y) + X, Y = np.meshgrid(X, Y) for i in range(self.ndim_image): for j in range(self.nsamples): ax[i].scatter(X, Y, self.data_matrix[j, :, :, i].T, **kwargs) @@ -829,20 +829,20 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, """ - if numpy.isscalar(shifts): + if np.isscalar(shifts): shifts = [shifts] - shifts = numpy.array(shifts) + shifts = np.array(shifts) # Case unidimensional treated as the multidimensional if self.ndim_domain == 1 and shifts.ndim == 1 and shifts.shape[0] != 1: - shifts = shifts[:, numpy.newaxis] + shifts = shifts[:, np.newaxis] # Case same shift for all the curves if shifts.shape[0] == self.ndim_domain and shifts.ndim ==1: # Column vector with shapes - shifts = numpy.atleast_2d(shifts).T + shifts = np.atleast_2d(shifts).T sample_points = self.sample_points + shifts domain_range = self.domain_range + shifts @@ -862,31 +862,31 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, if restrict_domain: - domain = numpy.asarray(self.domain_range) - a = domain[:,0] - numpy.atleast_1d(numpy.min(numpy.min(shifts, axis=1), 0)) - b = domain[:,1] - numpy.atleast_1d(numpy.max(numpy.max(shifts, axis=1), 0)) + domain = np.asarray(self.domain_range) + a = domain[:,0] - np.atleast_1d(np.min(np.min(shifts, axis=1), 0)) + b = domain[:,1] - np.atleast_1d(np.max(np.max(shifts, axis=1), 0)) - domain = numpy.vstack((a,b)).T + domain = np.vstack((a,b)).T eval_points = [eval_points[i][ - numpy.logical_and(eval_points[i] >= domain[i,0], + np.logical_and(eval_points[i] >= domain[i,0], eval_points[i] <= domain[i,1])] for i in range(self.ndim_domain)] else: domain = self.domain_range - eval_points = numpy.asarray(eval_points) + eval_points = np.asarray(eval_points) - eval_points_repeat = numpy.repeat(eval_points[numpy.newaxis, :], + eval_points_repeat = np.repeat(eval_points[np.newaxis, :], self.nsamples, axis=0) # Solve problem with cartesian and matrix indexing if self.ndim_domain > 1: - shifts[:,:2] = numpy.flip(shifts[:,:2], axis=1) + shifts[:,:2] = np.flip(shifts[:,:2], axis=1) - shifts = numpy.repeat(shifts[..., numpy.newaxis], + shifts = np.repeat(shifts[..., np.newaxis], eval_points.shape[1], axis=2) eval_points_shifted = eval_points_repeat + shifts @@ -921,7 +921,7 @@ def compose(self, fd, *, eval_points=None): # All composed with same function if fd.nsamples == 1 and self.nsamples != 1: - fd = fd.copy(data_matrix=numpy.repeat(fd.data_matrix, self.nsamples, + fd = fd.copy(data_matrix=np.repeat(fd.data_matrix, self.nsamples, axis=0)) if fd.ndim_domain == 1: @@ -929,7 +929,7 @@ def compose(self, fd, *, eval_points=None): try: eval_points = fd.sample_points[0] except: - eval_points = numpy.linspace(*fd.domain_range[0], 201) + eval_points = np.linspace(*fd.domain_range[0], 201) eval_points_transformation = fd(eval_points, keepdims=False) data_matrix = self(eval_points_transformation, @@ -942,14 +942,14 @@ def compose(self, fd, *, eval_points=None): lengths = [len(ax) for ax in eval_points] - eval_points_transformation = numpy.empty((self.nsamples, - numpy.prod(lengths), + eval_points_transformation = np.empty((self.nsamples, + np.prod(lengths), self.ndim_domain)) for i in range(self.nsamples): - eval_points_transformation[i] = numpy.array( - list(map(numpy.ravel, grid_transformation[i].T)) + eval_points_transformation[i] = np.array( + list(map(np.ravel, grid_transformation[i].T)) ).T data_flatten = self(eval_points_transformation, @@ -1006,7 +1006,7 @@ def __getitem__(self, key): def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): for i in inputs: - if isinstance(i, FDataGrid) and not numpy.all(i.sample_points == + if isinstance(i, FDataGrid) and not np.all(i.sample_points == self.sample_points): return NotImplemented diff --git a/tests/test_grid.py b/tests/test_grid.py index 608c8743c..266128607 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,6 +1,6 @@ import unittest -import numpy +import numpy as np import scipy.stats.mstats from skfda.exploratory import stats @@ -13,46 +13,46 @@ class TestFDataGrid(unittest.TestCase): def test_init(self): fd = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) - numpy.testing.assert_array_equal( + np.testing.assert_array_equal( fd.data_matrix[..., 0], - numpy.array([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]])) - numpy.testing.assert_array_equal(fd.sample_range, [(0, 1)]) - numpy.testing.assert_array_equal( - fd.sample_points, numpy.array([[0., 0.25, 0.5, 0.75, 1.]])) + np.array([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]])) + np.testing.assert_array_equal(fd.sample_range, [(0, 1)]) + np.testing.assert_array_equal( + fd.sample_points, np.array([[0., 0.25, 0.5, 0.75, 1.]])) def test_mean(self): fd = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) mean = stats.mean(fd) - numpy.testing.assert_array_equal( + np.testing.assert_array_equal( mean.data_matrix[0, ..., 0], - numpy.array([1.5, 2.5, 3.5, 4.5, 5.5])) - numpy.testing.assert_array_equal(fd.sample_range, [(0, 1)]) - numpy.testing.assert_array_equal( + np.array([1.5, 2.5, 3.5, 4.5, 5.5])) + np.testing.assert_array_equal(fd.sample_range, [(0, 1)]) + np.testing.assert_array_equal( fd.sample_points, - numpy.array([[0., 0.25, 0.5, 0.75, 1.]])) + np.array([[0., 0.25, 0.5, 0.75, 1.]])) def test_gmean(self): fd = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) mean = stats.gmean(fd) - numpy.testing.assert_array_equal( + np.testing.assert_array_equal( mean.data_matrix[0, ..., 0], scipy.stats.mstats.gmean( - numpy.array([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]))) - numpy.testing.assert_array_equal(fd.sample_range, [(0, 1)]) - numpy.testing.assert_array_equal( + np.array([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]))) + np.testing.assert_array_equal(fd.sample_range, [(0, 1)]) + np.testing.assert_array_equal( fd.sample_points, - numpy.array([[0., 0.25, 0.5, 0.75, 1.]])) + np.array([[0., 0.25, 0.5, 0.75, 1.]])) def test_slice(self): t = 10 - fd = FDataGrid(data_matrix=numpy.ones(t)) + fd = FDataGrid(data_matrix=np.ones(t)) fd = fd[:, 0] - numpy.testing.assert_array_equal( + np.testing.assert_array_equal( fd.data_matrix[..., 0], - numpy.array([[1]])) - numpy.testing.assert_array_equal( + np.array([[1]])) + np.testing.assert_array_equal( fd.sample_points, - numpy.array([[0]])) + np.array([[0]])) if __name__ == '__main__': From fa02b843b56f44dc46fbeb2eae3f2143092f481e Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 20 May 2019 18:56:55 +0200 Subject: [PATCH 024/222] Logic of axes labels moved to FData as a property with setter, indexing coordinates selects the corresponding labels, using @abstractproperty --- skfda/representation/_functional_data.py | 109 ++++++++++++++++++----- skfda/representation/basis.py | 36 ++++---- skfda/representation/grid.py | 67 +++++++++----- 3 files changed, 152 insertions(+), 60 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 290137e15..8e90132f6 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -4,7 +4,7 @@ objects of the package and contains some commons methods. """ -from abc import ABC, abstractmethod +from abc import ABC, abstractmethod, abstractproperty import numpy as np @@ -41,10 +41,29 @@ def __init__(self, extrapolation, dataset_label, axes_labels, keepdims): self.dataset_label = dataset_label self.axes_labels = axes_labels self.keepdims = keepdims - self._coordinate = None + self._coordinates = None @property - @abstractmethod + def axes_labels(self): + """Return the list of axes labels""" + return self._axes_labels + + @axes_labels.setter + def axes_labels(self, labels): + """Sets the list of labels""" + + if labels is not None: + + labels = np.asarray(labels) + if len(labels) != (self.ndim_domain + self.ndim_image): + raise ValueError("There must be a label for each of the" + "dimensions of the domain and the image.") + + self._axes_labels = labels + + + + @abstractproperty def nsamples(self): """Return the number of samples. @@ -54,8 +73,7 @@ def nsamples(self): """ pass - @property - @abstractmethod + @abstractproperty def ndim_domain(self): """Return number of dimensions of the domain. @@ -65,8 +83,7 @@ def ndim_domain(self): """ pass - @property - @abstractmethod + @abstractproperty def ndim_image(self): """Return number of dimensions of the image. @@ -86,9 +103,8 @@ def ndim_codomain(self): """ return self.ndim_image - @property - @abstractmethod - def coordinate(self): + @abstractproperty + def coordinates(self): r"""Return a component of the FDataGrid. If the functional object contains samples @@ -126,8 +142,7 @@ def extrapolator_evaluator(self): return self._extrapolator_evaluator - @property - @abstractmethod + @abstractproperty def domain_range(self): """Return the domain range of the object @@ -657,6 +672,55 @@ def set_figure_and_axes(self, nrows, ncols): return fig, ax + def _get_labels_coordinates(self, key): + """Return the labels of a function when it is indexed by its components. + + Args: + key (int, tuple, slice): Key used to index the coordinates. + + Returns: + (list): labels of the object fd.coordinates[key. + + """ + if self.axes_labels is None: + labels = None + else: + labels = list(self.axes_labels[:self.ndim_domain]) + labels.extend(list(self.axes_labels[self.ndim_domain:][key])) + + return labels + + def _join_labels_coordinates(self, *others): + """Return the labels of the concatenation as new coordinates of multiple + functional objects. + + Args: + others (:obj:`FData`) Obects to be concatenates. + + Returns: + (list): labels of the object + self.concatenate(*others, as_coordinates=True). + + """ + # Labels should be None or a list of length self.ndim_domain + + # self.ndim_image. + + if self.axes_labels is None: + labels = (self.ndim_domain + self.ndim_image) * [None] + else: + labels = self.axes_labels.tolist() + + for other in others: + if other.axes_labels is None: + labels.extend(other.ndim_image * [None]) + else: + labels.extend(list(other.axes_labels[self.ndim_domain:])) + + if all(label is None for label in labels): + labels = None + + return labels + def set_labels(self, fig=None, ax=None, patches=None): """Set labels if any. @@ -689,13 +753,18 @@ def set_labels(self, fig=None, ax=None, patches=None): if self.axes_labels is not None: if ax[0].name == '3d': for i in range(self.ndim_image): - ax[i].set_xlabel(self.axes_labels[0]) - ax[i].set_ylabel(self.axes_labels[1]) - ax[i].set_zlabel(self.axes_labels[i + 2]) + if self.axes_labels[0] is not None: + ax[i].set_xlabel(self.axes_labels[0]) + if self.axes_labels[1] is not None: + ax[i].set_ylabel(self.axes_labels[1]) + if self.axes_labels[i+2] is not None: + ax[i].set_zlabel(self.axes_labels[i + 2]) else: for i in range(self.ndim_image): - ax[i].set_xlabel(self.axes_labels[0]) - ax[i].set_ylabel(self.axes_labels[i + 1]) + if self.axes_labels[0] is not None: + ax[i].set_xlabel(self.axes_labels[0]) + if self.axes_labels[i + 1] is not None: + ax[i].set_ylabel(self.axes_labels[i + 1]) def generic_plotting_checks(self, fig=None, ax=None, nrows=None, ncols=None): @@ -1002,7 +1071,7 @@ def to_basis(self, basis, eval_points=None, **kwargs): pass @abstractmethod - def concatenate(self, *others, as_coordinate=False): + def concatenate(self, *others, as_coordinates=False): """Join samples from a similar FData object. Joins samples from another FData object if it has the same @@ -1010,9 +1079,9 @@ def concatenate(self, *others, as_coordinate=False): Args: others (:class:`FData`): other FData objects. - as_coordinate (boolean, optional): If False concatenates as + as_coordinates (boolean, optional): If False concatenates as new samples, else, concatenates the other functions as - new componentes of the image. Defaults to False. + new components of the image. Defaults to False. Returns: :class:`FData`: FData object with the samples from the two diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 4bfa27285..49a4d1a9c 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1489,11 +1489,6 @@ def __init__(self, basis, coefficients, *, dataset_label=None, self.basis = basis self.coefficients = coefficients - #self.dataset_label = dataset_label - #self.axes_labels = axes_labels - #self.extrapolation = extrapolation - #self.keepdims = keepdims - super().__init__(extrapolation, dataset_label, axes_labels, keepdims) @@ -1732,7 +1727,7 @@ def ndim_image(self): return 1 @property - def coordinate(self,): + def coordinates(self): r"""Return a component of the FDataBasis. If the functional object contains samples @@ -1741,14 +1736,14 @@ def coordinate(self,): Todo: - By the moment only unidimensional objects are supported in basis + By the moment, only unidimensional objects are supported in basis form. """ - if self._coordinate is None: - self._coordinate = FDataBasis._CoordinateIterator(self) + if self._coordinates is None: + self._coordinates = FDataBasis._CoordinateIterator(self) - return self._coordinate + return self._coordinates @property def nbasis(self): @@ -2228,11 +2223,16 @@ def _array_to_R(self, coefficients, transpose=False): def __repr__(self): """Representation of FDataBasis object.""" + if self.axes_labels is None: + axes_labels = None + else: + axes_labels = self.axes_labels.tolist() + return (f"{self.__class__.__name__}(" f"\nbasis={self.basis}," f"\ncoefficients={self.coefficients}," f"\ndataset_label={self.dataset_label}," - f"\naxes_labels={self.axes_labels}," + f"\naxes_labels={axes_labels}," f"\nextrapolation={self.extrapolation}," f"\nkeepdims={self.keepdims})").replace('\n', '\n ') @@ -2249,7 +2249,7 @@ def __eq__(self, other): return (self.basis == other.basis and numpy.all(self.coefficients == other.coefficients)) - def concatenate(self, *others, as_coordinate=False): + def concatenate(self, *others, as_coordinates=False): """Join samples from a similar FDataBasis object. Joins samples from another FDataBasis object if they have the same @@ -2257,16 +2257,20 @@ def concatenate(self, *others, as_coordinate=False): Args: others (:class:`FDataBasis`): other FDataBasis objects. - as_coordinate (boolean, optional): If False concatenates as + as_coordinates (boolean, optional): If False concatenates as new samples, else, concatenates the other functions as - new componentes of the image. Defaults to False. + new components of the image. Defaults to False. Returns: - :class:`FDataBasis`: FDataBasis object with the samples from the two + :class:`FDataBasis`: FDataBasis object with the samples from the original objects. + + Todo: + By the moment, only unidimensional objects are supported in basis + form. """ - if as_coordinate: + if as_coordinates: return NotImplemented for other in others: diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 8ec07b08d..edfa89f81 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -105,14 +105,19 @@ def __init__(self, fdatagrid): def __iter__(self): """Return an iterator through the image coordinates.""" + for k in range(len(self)): yield self._fdatagrid.copy( - data_matrix=self._fdatagrid.data_matrix[..., k]) + data_matrix=self._fdatagrid.data_matrix[..., k], + axes_labels=self._fdatagrid._get_labels_coordinates(k)) def __getitem__(self, key): """Get a specific coordinate.""" + axes_labels = self._fdatagrid._get_labels_coordinates(key) + return self._fdatagrid.copy( - data_matrix=self._fdatagrid.data_matrix[..., key]) + data_matrix=self._fdatagrid.data_matrix[..., key], + axes_labels=axes_labels) def __len__(self): """Return the number of coordinates.""" @@ -193,10 +198,6 @@ def __init__(self, data_matrix, sample_points=None, if self.data_matrix.ndim == 1 + self.ndim_domain: self.data_matrix = self.data_matrix[..., numpy.newaxis] - if axes_labels is not None and len(axes_labels) != (self.ndim_domain + self.ndim_image): - raise ValueError("There must be a label for each of the" - "dimensions of the domain and the image.") - self.interpolator = interpolator @@ -249,7 +250,7 @@ def ndim_image(self): return 1 @property - def coordinate(self): + def coordinates(self): r"""Returns an object to access to the image coordinates. If the functional object contains multivariate samples @@ -302,10 +303,10 @@ def coordinate(self): 3 """ - if self._coordinate is None: - self._coordinate = FDataGrid._CoordinateIterator(self) + if self._coordinates is None: + self._coordinates = FDataGrid._CoordinateIterator(self) - return self._coordinate + return self._coordinates @property def ndim(self): @@ -700,7 +701,7 @@ def __rtruediv__(self, other): return self.copy(data_matrix=data_matrix / self.data_matrix) - def concatenate(self, *others, as_coordinate=False): + def concatenate(self, *others, as_coordinates=False): """Join samples from a similar FDataGrid object. Joins samples from another FDataGrid object if it has the same @@ -708,12 +709,12 @@ def concatenate(self, *others, as_coordinate=False): Args: others (:obj:`FDataGrid`): another FDataGrid object. - as_coordinate (boolean, optional): If False concatenates as + as_coordinates (boolean, optional): If False concatenates as new samples, else, concatenates the other functions as - new componentes of the image. Defaults to false. + new components of the image. Defaults to false. Returns: - :obj:`FDataGrid`: FDataGrid object with the samples from the two + :obj:`FDataGrid`: FDataGrid object with the samples from the original objects. Examples: @@ -738,19 +739,31 @@ def concatenate(self, *others, as_coordinate=False): """ # Checks - for other in others: - self.__check_same_dimensions(other) + if not as_coordinates: + for other in others: + self.__check_same_dimensions(other) + + elif not all([numpy.array_equal(self.sample_points, other.sample_points) + for other in others]): + raise ValueError("All the FDataGrids must be sampled in the same " + "sample points.") + + elif any([self.nsamples != other.nsamples for other in others]): + + raise ValueError(f"All the FDataGrids must contain the same " + f"number of samples {self.nsamples} to " + f"concatenate as a new coordinate.") - if as_coordinate: - if any([self.nsamples != other.nsamples for other in others]): - raise ValueError(f"All the FDataGrids must contain the same " - f"number of samples {self.nsamples} to " - f"concatenate as a new coordinate.") data = [self.data_matrix] + [other.data_matrix for other in others] - axis = 0 if as_coordinate is False else -1 - return self.copy(data_matrix=numpy.concatenate(data, axis=axis)) + + if as_coordinates: + return self.copy(data_matrix=numpy.concatenate(data, axis=-1), + axes_labels=self._join_labels_coordinates(*others)) + + else: + return self.copy(data_matrix=numpy.concatenate(data, axis=0)) def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): @@ -1064,12 +1077,18 @@ def __str__(self): def __repr__(self): """Return repr(self).""" + + if self.axes_labels is None: + axes_labels = None + else: + axes_labels = self.axes_labels.tolist() + return (f"FDataGrid(" f"\n{repr(self.data_matrix)}," f"\nsample_points={repr(self.sample_points)}," f"\ndomain_range={repr(self.domain_range)}," f"\ndataset_label={repr(self.dataset_label)}," - f"\naxes_labels={repr(self.axes_labels)}," + f"\naxes_labels={repr(axes_labels)}," f"\nextrapolation={repr(self.extrapolation)}," f"\ninterpolator={repr(self.interpolator)}," f"\nkeepdims={repr(self.keepdims)})").replace('\n', '\n ') From 528cd6f460864d0f654acae0f99ea299cfe054a6 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 20 May 2019 19:03:08 +0200 Subject: [PATCH 025/222] Doctest fixed --- skfda/representation/grid.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index edfa89f81..782968715 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -271,7 +271,7 @@ def coordinates(self): :math:`f(t) = (f_0(t), f_1(t), f_2(t))`. We can obtain a specific component of the vector, for example, the first one. - >>> fd_0 = fd.coordinate[0] + >>> fd_0 = fd.coordinates[0] >>> fd_0 FDataGrid(...) @@ -283,13 +283,13 @@ def coordinates(self): Or we can get multiple components, it can be accesed as a 1-d numpy array of coordinates, for example, :math:`(f_0(t), f_1(t))`. - >>> fd_01 = fd.coordinate[0:2] + >>> fd_01 = fd.coordinates[0:2] >>> fd_01.ndim_image 2 We can use this method to iterate throught all the coordinates. - >>> for fd_i in fd.coordinate: + >>> for fd_i in fd.coordinates: ... fd_i.ndim_image 1 1 @@ -298,7 +298,7 @@ def coordinates(self): This object can be used to split a FDataGrid in a list with their components. - >>> fd_list = list(fd.coordinate) + >>> fd_list = list(fd.coordinates) >>> len(fd_list) 3 From 970e731f20147d5737eb803acd7e4aba3fba5355 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 20 May 2019 20:29:02 +0200 Subject: [PATCH 026/222] Test of indexation --- skfda/representation/_functional_data.py | 14 ++-- tests/test_grid.py | 85 ++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 8e90132f6..52d919ed8 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -55,9 +55,12 @@ def axes_labels(self, labels): if labels is not None: labels = np.asarray(labels) - if len(labels) != (self.ndim_domain + self.ndim_image): - raise ValueError("There must be a label for each of the" + if len(labels) > (self.ndim_domain + self.ndim_image): + raise ValueError("There must be a label for each of the " "dimensions of the domain and the image.") + if len(labels) < (self.ndim_domain + self.ndim_image): + diff = (self.ndim_domain + self.ndim_image) - len(labels) + labels = np.concatenate((labels, diff*[None])) self._axes_labels = labels @@ -685,8 +688,11 @@ def _get_labels_coordinates(self, key): if self.axes_labels is None: labels = None else: - labels = list(self.axes_labels[:self.ndim_domain]) - labels.extend(list(self.axes_labels[self.ndim_domain:][key])) + + labels = self.axes_labels[:self.ndim_domain].tolist() + image_label = np.atleast_1d(self.axes_labels[self.ndim_domain:][key]) + labels.extend(image_label.tolist()) + return labels diff --git a/tests/test_grid.py b/tests/test_grid.py index 608c8743c..62c574c59 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -54,6 +54,91 @@ def test_slice(self): fd.sample_points, numpy.array([[0]])) + def test_concatenate(self): + fd1 = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) + fd2 = FDataGrid([[3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) + + fd1.axes_labels = ["x", "y"] + fd = fd1.concatenate(fd2) + + numpy.testing.assert_equal(fd.nsamples, 4) + numpy.testing.assert_equal(fd.ndim_image, 1) + numpy.testing.assert_equal(fd.ndim_domain, 1) + numpy.testing.assert_array_equal(fd.data_matrix[..., 0], + [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], + [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) + numpy.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) + + def test_concatenate(self): + fd1 = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) + fd2 = FDataGrid([[3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) + + fd1.axes_labels = ["x", "y"] + fd = fd1.concatenate(fd2) + + numpy.testing.assert_equal(fd.nsamples, 4) + numpy.testing.assert_equal(fd.ndim_image, 1) + numpy.testing.assert_equal(fd.ndim_domain, 1) + numpy.testing.assert_array_equal(fd.data_matrix[..., 0], + [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], + [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) + numpy.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) + + def test_concatenate_coordinates(self): + fd1 = FDataGrid([[1, 2, 3, 4], [2, 3, 4, 5]]) + fd2 = FDataGrid([[3, 4, 5, 6], [4, 5, 6, 7]]) + + fd1.axes_labels = ["x", "y"] + fd2.axes_labels = ["w", "t"] + fd = fd1.concatenate(fd2, as_coordinates=True) + + numpy.testing.assert_equal(fd.nsamples, 2) + numpy.testing.assert_equal(fd.ndim_image, 2) + numpy.testing.assert_equal(fd.ndim_domain, 1) + + numpy.testing.assert_array_equal(fd.data_matrix, + [[[1, 3], [2, 4], [3, 5], [4, 6]], + [[2, 4], [3, 5], [4, 6], [5, 7]]]) + + # Testing labels + numpy.testing.assert_array_equal(["x", "y", "t"], fd.axes_labels) + fd1.axes_labels = ["x", "y"] + fd2.axes_labels = None + fd = fd1.concatenate(fd2, as_coordinates=True) + numpy.testing.assert_array_equal(["x", "y", None], fd.axes_labels) + fd1.axes_labels = None + fd = fd1.concatenate(fd2, as_coordinates=True) + numpy.testing.assert_equal(None, fd.axes_labels) + + def test_coordinates(self): + fd1 = FDataGrid([[1, 2, 3, 4], [2, 3, 4, 5]]) + fd1.axes_labels = ["x", "y"] + fd2 = FDataGrid([[3, 4, 5, 6], [4, 5, 6, 7]]) + fd = fd1.concatenate(fd2, as_coordinates=True) + + # Indexing with number + numpy.testing.assert_array_equal(fd.coordinates[0].data_matrix, + fd1.data_matrix) + numpy.testing.assert_array_equal(fd.coordinates[1].data_matrix, + fd2.data_matrix) + + # Iteration + for fd_j, fd_i in zip([fd1, fd2], fd.coordinates): + numpy.testing.assert_array_equal(fd_j.data_matrix, fd_i.data_matrix) + + fd3 = fd1.concatenate(fd2, fd1, fd, as_coordinates=True) + + # Multiple indexation + numpy.testing.assert_equal(fd3.ndim_image, 5) + numpy.testing.assert_array_equal(fd3.coordinates[:2].data_matrix, + fd.data_matrix) + numpy.testing.assert_array_equal(fd3.coordinates[-2:].data_matrix, + fd.data_matrix) + numpy.testing.assert_array_equal( + fd3.coordinates[(False, False, True, False, True)].data_matrix, + fd.data_matrix) + + if __name__ == '__main__': print() From 4f1a29919c7bcf31e78c9e577209f0a02d93ad09 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Mon, 20 May 2019 22:43:51 +0200 Subject: [PATCH 027/222] Some tests for scalar regression --- skfda/ml/regression/scalar.py | 19 ++++++------------- tests/test_regression.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/skfda/ml/regression/scalar.py b/skfda/ml/regression/scalar.py index 9fab8a435..522703cfc 100644 --- a/skfda/ml/regression/scalar.py +++ b/skfda/ml/regression/scalar.py @@ -5,9 +5,9 @@ class ScalarRegression: - def __init__(self, beta, wt=None): + def __init__(self, beta, weights=None): self.beta = beta - self.weights = wt + self.weights = weights def fit(self, y, x): @@ -18,13 +18,9 @@ def fit(self, y, x): y = np.array(y).reshape((nsamples, 1)) - Zmat = None - Rmat = None - - for j in range(0, nbeta): - xfdj = x[j] - xcoef = xfdj.coefficients - xbasis = xfdj.basis + for j in range(nbeta): + xcoef = x[j].coefficients + xbasis = x[j].basis Jpsithetaj = xbasis.inner_product(beta[j]) Zmat = xcoef @ Jpsithetaj if j == 0 else np.concatenate( (Zmat, xcoef @ Jpsithetaj), axis=1) @@ -33,18 +29,15 @@ def fit(self, y, x): rtwt = np.sqrt(wt) Zmatwt = Zmat * rtwt ymatwt = y * rtwt - Cmat = np.transpose(Zmatwt @ Zmatwt + Rmat) + Cmat = np.transpose(Zmatwt @ Zmatwt) Dmat = np.transpose(Zmatwt) @ ymatwt else: Cmat = np.transpose(Zmat) @ Zmat Dmat = np.transpose(Zmat) @ y - # eigchk(Cmat) Cmatinv = np.linalg.inv(Cmat) betacoef = Cmatinv @ Dmat - df = np.sum(np.diag(Zmat @ Cmatinv @ np.transpose(Zmat))) - mj2 = 0 for j in range(0, nbeta): mj1 = mj2 diff --git a/tests/test_regression.py b/tests/test_regression.py index 774354759..b72f66b30 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,6 +1,40 @@ import unittest +from skfda.representation.basis import Monomial, Fourier, FDataBasis +from skfda.ml.regression.scalar import ScalarRegression +import numpy as np +class TestRegression(unittest.TestCase): + """Test regression""" + def test_scalar_regression(self): + beta_Basis = Fourier(nbasis=5) + beta_fd = FDataBasis(beta_Basis, [1, 2, 3, 4, 5]) + + x_Basis = Monomial(nbasis=7) + x_fd = FDataBasis(x_Basis, np.identity(7)) + + scalar_test = ScalarRegression([beta_fd]) + y = scalar_test.predict([x_fd]) + + scalar = ScalarRegression([beta_Basis]) + scalar.fit(y, [x_fd]) + np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, + beta_fd.coefficients) + + beta_Basis = Fourier(nbasis=5) + beta_fd = FDataBasis(beta_Basis, [1, 1, 1, 1, 1]) + y = [1.0000684777229512, + 0.1623672257830915, + 0.08521053851548224, + 0.08514200869281137, + 0.09529138749665378, + 0.10549625973303875, + 0.11384314859153018] + + scalar = ScalarRegression([beta_Basis]) + scalar.fit(y, [x_fd]) + np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, + beta_fd.coefficients) if __name__ == '__main__': print() From 440e894c16423e00430109f367c72a5e556b8401 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Tue, 21 May 2019 23:54:09 +0200 Subject: [PATCH 028/222] Updated some numpy references to match np naming --- skfda/representation/basis.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 5c4a0ae29..569f6b6af 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -333,7 +333,7 @@ def _add_same_basis(self, coefs1, coefs2): def _add_constant(self, coefs, constant): coefs = coefs.copy() - constant = numpy.array(constant) + constant = np.array(constant) coefs[:, 0] = coefs[:, 0] + constant return self.copy(), coefs @@ -343,14 +343,14 @@ def _sub_same_basis(self, coefs1, coefs2): def _sub_constant(self, coefs, other): coefs = coefs.copy() - other = numpy.array(other) + other = np.array(other) coefs[:, 0] = coefs[:, 0] - other return self.copy(), coefs def _mul_constant(self, coefs, other): coefs = coefs.copy() - other = numpy.atleast_2d(other).reshape(-1, 1) + other = np.atleast_2d(other).reshape(-1, 1) coefs = coefs * other return self.copy(), coefs @@ -2345,7 +2345,7 @@ def __rmul__(self, other): def __truediv__(self, other): """Division for FDataBasis object.""" - other = numpy.array(other) + other = np.array(other) try: other = 1 / other From 51599de49e21083904c2ce2a7b90aaf501724081 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 23 May 2019 00:12:24 +0200 Subject: [PATCH 029/222] Improve centroid visualization in `plot_clusters` * The centroid are now plotted in a darker version of the color of the respective cluster. * The width of the line of the centroid is configurable, and by default wider than the others. --- .../visualization/clustering_plots.py | 52 +++++++++++++++++-- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering_plots.py index 87f42d833..e06c3cdb7 100644 --- a/skfda/exploratory/visualization/clustering_plots.py +++ b/skfda/exploratory/visualization/clustering_plots.py @@ -13,6 +13,44 @@ __email__ = "amanda.hernando@estudiante.uam.es" +def _change_luminosity(color, amount=0.5): + """ + Changes the given color luminosity by the given amount. + Input can be matplotlib color string, hex string, or RGB tuple. + + Note: + Based on https://stackoverflow.com/a/49601444/2455333 + """ + import matplotlib.colors as mc + import colorsys + try: + c = mc.cnames[color] + except TypeError: + c = color + c = colorsys.rgb_to_hls(*mc.to_rgb(c)) + + intensity = (amount - 0.5) * 2 + up = intensity > 0 + intensity = abs(intensity) + + lightness = c[1] + if up: + new_lightness = lightness + intensity * (1 - lightness) + else: + new_lightness = lightness - intensity * lightness + + return colorsys.hls_to_rgb(c[0], new_lightness, c[2]) + + +def _darken(color, amount=0): + return _change_luminosity(color, 0.5 - amount/2) + + +def _lighten(color, amount=0): + return _change_luminosity(color, 0.5 + amount/2) + + + def _check_if_estimator(estimator): """Checks the argument *estimator* is actually an estimator that implements the *fit* method. @@ -86,7 +124,7 @@ def _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, sample_labels, cluster_colors, cluster_labels, - center_colors, center_labels, colormap): + center_colors, center_labels, center_width, colormap): """Implementation of the plot of the FDataGrid samples by clusters. Args: @@ -118,6 +156,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, centroid of the clusters the samples of the fdatagrid are classified into. center_labels list of colors): contains in order the labels of each centroid of the clusters the samples of the fdatagrid are classified into. + center_width (int): width of the centroids. colormap(colormap): colormap from which the colors of the plot are taken. Returns: @@ -146,7 +185,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, range(estimator.n_clusters)] if center_colors is None: - center_colors = ["black"] * estimator.n_clusters + center_colors = [_darken(c, 0.5) for c in cluster_colors] if center_labels is None: center_labels = ['$CENTER: {}$'.format(i) for i in @@ -169,7 +208,8 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, for i in range(estimator.n_clusters): ax[j].plot(fdatagrid.sample_points[0], estimator.cluster_centers_.data_matrix[i, :, j], - c=center_colors[i], label=center_labels[i]) + c=center_colors[i], label=center_labels[i], + linewidth=center_width) ax[j].legend(handles=patches) datacursor(formatter='{label}'.format) @@ -181,6 +221,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, sample_labels=None, cluster_colors=None, cluster_labels=None, center_colors=None, center_labels=None, + center_width=3, colormap=plt.cm.get_cmap('rainbow')): """Plot of the FDataGrid samples by clusters. @@ -215,6 +256,7 @@ def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, centroid of the clusters the samples of the fdatagrid are classified into. center_labels (list of colors): contains in order the labels of each centroid of the clusters the samples of the fdatagrid are classified into. + center_width (int): width of the centroid curves. colormap(colormap): colormap from which the colors of the plot are taken. Defaults to `rainbow`. @@ -244,7 +286,9 @@ def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, cluster_colors=cluster_colors, cluster_labels=cluster_labels, center_colors=center_colors, - center_labels=center_labels, colormap=colormap) + center_labels=center_labels, + center_width=center_width, + colormap=colormap) def _set_labels(xlabel, ylabel, title, xlabel_str): From 9943c73b2490f7aff91b45133719346df5b00918 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 23 May 2019 00:18:31 +0200 Subject: [PATCH 030/222] Change membership grade to degree of membership --- skfda/exploratory/visualization/clustering_plots.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering_plots.py index e06c3cdb7..2a2a2385f 100644 --- a/skfda/exploratory/visualization/clustering_plots.py +++ b/skfda/exploratory/visualization/clustering_plots.py @@ -314,10 +314,10 @@ def _set_labels(xlabel, ylabel, title, xlabel_str): xlabel = xlabel_str if ylabel is None: - ylabel = "Membership grade" + ylabel = "Degree of membership" if title is None: - title = "Membership grades of the samples to each cluster" + title = "Degrees of membership of the samples to each cluster" return xlabel, ylabel, title @@ -399,9 +399,9 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, cluster the samples of the fdatagrid are classified into. colormap(colormap, optional): colormap from which the colors of the plot are taken. xlabel (str): Label for the x-axis. Defaults to "Sample". - ylabel (str): Label for the y-axis. Defaults to "Membership grade". + ylabel (str): Label for the y-axis. Defaults to "Degree of membership". title (str, optional): Title for the figure where the clustering results are ploted. - Defaults to "Membership grades of the samples to each cluster". + Defaults to "Degrees of membership of the samples to each cluster". Returns: (tuple): tuple containing: @@ -495,9 +495,9 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, cluster the samples of the fdatagrid are classified into. colormap(colormap, optional): colormap from which the colors of the plot are taken. xlabel (str): Label for the x-axis. Defaults to "Sample". - ylabel (str): Label for the y-axis. Defaults to "Membership grade". + ylabel (str): Label for the y-axis. Defaults to "Degree of membership". title (str): Title for the figure where the clustering results are plotted. - Defaults to "Membership grades of the samples to each cluster". + Defaults to "Degrees of membership of the samples to each cluster". Returns: (tuple): tuple containing: From d12189a673da8a4d43a68bde6cf0dfeb565d1eac Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 23 May 2019 01:07:46 +0200 Subject: [PATCH 031/222] Representation example comparison --- examples/plot_representation.py | 13 +++++++++++-- skfda/representation/_functional_data.py | 1 + 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/examples/plot_representation.py b/examples/plot_representation.py index b9bd18a69..dae7463ff 100644 --- a/examples/plot_representation.py +++ b/examples/plot_representation.py @@ -87,11 +87,20 @@ ############################################################################### # We can increase the number of elements in the basis to try to reproduce the # original data with more fidelity. -fd_basis = fd.to_basis( +fd_basis_big = fd.to_basis( basis.BSpline(domain_range=fd.domain_range[0], nbasis=7) ) -fd_basis.plot() +fd_basis_big.plot() + +############################################################################## +# Lets compare the diferent representations in the same plot, for the same +# curve +fig, ax = fd[0].plot() +fd_basis[0].plot(fig) +fd_basis_big[0].plot(fig) + +ax[0].legend(['Original', '4 elements', '7 elements']) ############################################################################## # We can also see the effect of changing the basis. diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 7ec984506..ebf8814e5 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -833,6 +833,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, next_color = False if sample_labels is not None: + sample_labels = np.asarray(sample_labels) nlabels = np.max(sample_labels) + 1 From 1b619875836ef45ea66edc74d22437500dbbbe2a Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 23 May 2019 01:13:32 +0200 Subject: [PATCH 032/222] Correct sentence about Fourier basis. --- examples/plot_representation.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/examples/plot_representation.py b/examples/plot_representation.py index dae7463ff..cda6a5c4a 100644 --- a/examples/plot_representation.py +++ b/examples/plot_representation.py @@ -105,7 +105,8 @@ ############################################################################## # We can also see the effect of changing the basis. # For example, in the Fourier basis the functions start and end at the same -# points, so this basis is clearly non suitable for the Growth dataset. +# points if the period is equal to the domain range, so this basis is clearly +# non suitable for the Growth dataset. fd_basis = fd.to_basis( basis.Fourier(domain_range=fd.domain_range[0], nbasis=7) ) From 134d3d485e85cc73a501abd564844fc5faa9b3b8 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 23 May 2019 08:47:51 +0200 Subject: [PATCH 033/222] Clustering labels and colors were wrong --- examples/plot_clustering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/plot_clustering.py b/examples/plot_clustering.py index f22ba6cc0..cd43aa8b1 100644 --- a/examples/plot_clustering.py +++ b/examples/plot_clustering.py @@ -87,8 +87,8 @@ # Customization of cluster colors and labels in order to match the first image # of raw data. -cluster_colors = climate_colors[np.array([1, 2, 0])] -cluster_labels = climates[np.array([1, 2, 0])] +cluster_colors = climate_colors[np.array([0, 2, 1])] +cluster_labels = climates[np.array([0, 2, 1])] plot_clusters(kmeans, fd, cluster_colors=cluster_colors, cluster_labels=cluster_labels) From 0d797269fc89d254ef8ff8e92942732985010e04 Mon Sep 17 00:00:00 2001 From: pablomm Date: Fri, 24 May 2019 16:44:16 +0200 Subject: [PATCH 034/222] Neighbors in progress --- skfda/__init__.py | 2 +- skfda/ml/__init__.py | 2 + skfda/ml/_neighbors.py | 502 ++++++++++++++++++++++++++++ skfda/ml/classification/__init__.py | 3 + skfda/ml/regression/__init__.py | 1 + 5 files changed, 509 insertions(+), 1 deletion(-) create mode 100644 skfda/ml/_neighbors.py diff --git a/skfda/__init__.py b/skfda/__init__.py index fe8c4b32e..1330382f4 100644 --- a/skfda/__init__.py +++ b/skfda/__init__.py @@ -27,7 +27,7 @@ from .representation import FDataBasis from .representation import FDataGrid -from . import representation, datasets, preprocessing, exploratory, misc +from . import representation, datasets, preprocessing, exploratory, misc, ml import os as _os diff --git a/skfda/ml/__init__.py b/skfda/ml/__init__.py index e69de29bb..a65e22c8c 100644 --- a/skfda/ml/__init__.py +++ b/skfda/ml/__init__.py @@ -0,0 +1,2 @@ + +from . import classification, clustering, regression diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py new file mode 100644 index 000000000..88c9b34dd --- /dev/null +++ b/skfda/ml/_neighbors.py @@ -0,0 +1,502 @@ +"""Module with classes to neighbors classification and regression.""" + +from abc import ABCMeta, abstractmethod, abstractproperty + +from sklearn.base import BaseEstimator, ClassifierMixin +from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted + +# Sklearn classes to be wrapped +from sklearn.neighbors import NearestNeighbors as _NearestNeighbors +from sklearn.neighbors import KNeighborsClassifier as _KNeighborsClassifier +from sklearn.neighbors import (RadiusNeighborsClassifier as + _RadiusNeighborsClassifier) + +from .. import FDataGrid +from ..misc.metrics import lp_distance + +def _to_multivariate(fdatagrid): + r"""Returns the data matrix of a fdatagrid in flatten form compatible with + sklearn. + + Args: + fdatagrid (:class:`FDataGrid`): Grid to be converted to matrix + + Returns: + (np.array): Numpy array with size (nsamples, points), where + points = prod([len(d) for d in fdatagrid.sample_points] + + """ + return fdatagrid.data_matrix.reshape(fdatagrid.nsamples, -1) + + +def _from_multivariate(data_matrix, sample_points, shape, **kwargs): + r"""Constructs a FDatagrid from the data matrix flattened. + + Args: + data_matrix (np.array): Data Matrix flattened as multivariate vector + compatible with sklearn. + sample_points (array_like): List with sample points for each dimension. + shape (tuple): Shape of the data_matrix. + **kwargs: Named params to be passed to the FDataGrid constructor. + + Returns: + (:class:`FDataGrid`): FDatagrid with the data. + + """ + return FDataGrid(data_matrix.reshape(shape), sample_points, **kwargs) + + +def _to_sklearn_metric(metric, sample_points, check=True): + r"""Transform a metric between FDatagrid in a sklearn compatible one. + + Given a metric between FDatagrids returns a compatible metric used to + wrap the sklearn routines. + + Args: + metric (pyfunc): Metric of the module `mics.metrics`. Must accept + two FDataGrids and return a float representing the distance. + sample_points (array_like): Array of arrays with the sample points of + the FDataGrids. + check (boolean, optional): If False it is passed the named parameter + `check=False` to avoid the repetition of checks in internal + routines. + + Returns: + (pyfunc): sklearn vector metric. + + Examples: + + >>> import numpy as np + >>> from skfda import FDataGrid + >>> from skfda.misc.metrics import lp_distance + >>> from skfda.ml._neighbors import _to_sklearn_metric + + Calculate the Lp distance between fd and fd2. + >>> x = np.linspace(0, 1, 101) + >>> fd = FDataGrid([np.ones(len(x))], x) + >>> fd2 = FDataGrid([np.zeros(len(x))], x) + >>> lp_distance(fd, fd2).round(2) + 1.0 + + Creation of the sklearn-style metric. + >>> sklearn_lp_distance = _to_sklearn_metric(lp_distance, x) + >>> sklearn_lp_distance(np.ones(len(x)), np.zeros(len(x))).round(2) + 1.0 + + """ + # Shape -> (Nsamples = 1, domain_dims...., image_dimension (-1)) + shape = [1] + [len(axis) for axis in sample_points] + [-1] + + if check: + def sklearn_metric(x, y, **kwargs): + + return metric(_from_multivariate(x, sample_points, shape), + _from_multivariate(y, sample_points, shape), **kwargs) + else: + def sklearn_metric(x, y, **kwargs): + + return metric(_from_multivariate(x, sample_points, shape), + _from_multivariate(y, sample_points, shape), + check=False, **kwargs) + + return sklearn_metric + + +class NeighborsBase(BaseEstimator, metaclass=ABCMeta): + """Base class for nearest neighbors estimators.""" + + @abstractmethod + def __init__(self, n_neighbors=None, radius=None, + weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=None): + + self.n_neighbors = n_neighbors + self.radius = radius + self.weights = weights + self.algorithm = algorithm + self.leaf_size = leaf_size + self.metric = metric + self.metric_params = metric_params + self.n_jobs = n_jobs + + + @abstractmethod + def _init_estimator(self, sk_metric): + """Initializes the estimator returned by :meth:`_sklearn_neighbors`.""" + pass + + def fit(self, X, y): + """Fit the model using X as training data and y as target values. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + y (array-like or sparse matrix): Target values of + shape = [n_samples] or [n_samples, n_outputs]. + + Note: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + # If metric is precomputed no different with the Sklearn stimator + if self.metric == 'precomputed': + self.estimator_ = self._init_estimator(self.metric) + self.estimator_.fit(X, y) + else: + self._sample_points = X.sample_points + self._shape = X.data_matrix.shape[1:] + + # Constructs sklearn metric to manage vector instead of FDatagrids + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + + self.estimator_ = self._init_estimator(sk_metric) + print(self.estimator_) + self.estimator_.fit(self._transform_to_multivariate(X), y) + + return self + + + def _check_is_fitted(self): + """Check if the estimator is fitted. + + Raise: + NotFittedError: If the estimator is not fitted. + + """ + sklearn_check_is_fitted(self, ['estimator_']) + + + def _transform_to_multivariate(self, X): + """Transform the input data to array form. If the metric is + precomputed it is not transformet. + + """ + if X is not None and self.metric != 'precomputed': + X = _to_multivariate(X) + + return X + + def _transform_from_multivariate(self, X): + """Transform from array like to FDatagrid.""" + + if X.ndim == 1: + shape = (1, ) + self._shape + else: + shape = (len(X), ) + self._shape + + return _from_multivariate(X, self._sample_points, shape) + + +class KNeighborsMixin: + """Mixin class for K-Neighbors""" + + def kneighbors(self, X=None, n_neighbors=None, return_distance=True): + """Finds the K-neighbors of a point. + Returns indices of and distances to the neighbors of each point. + + Args: + X (:class:`FDataGrid` or matrix): FDatagrid with the query functions + or matrix (n_query, n_indexed) if metric == 'precomputed'. If + not provided, neighbors of each indexed point are returned. In + this case, the query point is not considered its own neighbor. + n_neighbors (int): Number of neighbors to get (default is the value + passed to the constructor). + return_distance (boolean, optional): Defaults to True. If False, + distances will not be returned. + + Returns: + dist : array + Array representing the lengths to points, only present if + return_distance=True + ind : array + Indices of the nearest points in the population matrix. + + Notes: + This method wraps the corresponding sklearn routine in the + module ``sklearn.neighbors``. + + """ + self._check_is_fitted() + X = self._transform_to_multivariate(X) + + return self.estimator_.kneighbors(X, n_neighbors, return_distance) + + def kneighbors_graph(self, X=None, n_neighbors=None, mode='connectivity'): + """Computes the (weighted) graph of k-Neighbors for points in X + + Args: + X (:class:`FDataGrid` or matrix): FDatagrid with the query functions + or matrix (n_query, n_indexed) if metric == 'precomputed'. If + not provided, neighbors of each indexed point are returned. In + this case, the query point is not considered its own neighbor. + n_neighbors (int): Number of neighbors to get (default is the value + passed to the constructor). + mode ('connectivity' or 'distance', optional): Type of returned + matrix: 'connectivity' will return the connectivity matrix with + ones and zeros, in 'distance' the edges are distance between + points. + + Returns: + Sparse matrix in CSR format, shape = [n_samples, n_samples_fit] + n_samples_fit is the number of samples in the fitted data + A[i, j] is assigned the weight of edge that connects i to j. + + See also: + NearestNeighbors.radius_neighbors_graph + + Notes: + This method wraps the corresponding sklearn routine in the + module ``sklearn.neighbors``. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.kneighbors_graph(X, n_neighbors, mode) + +class RadiusNeighborsMixin: + """Mixin Class for Raius Neighbors""" + + def radius_neighbors(self, X=None, radius=None, return_distance=True): + """Finds the neighbors within a given radius of a fdatagrid or + fdatagrids. + Return the indices and distances of each point from the dataset + lying in a ball with size ``radius`` around the points of the query + array. Points lying on the boundary are included in the results. + The result points are *not* necessarily sorted by distance to their + query point. + + Args: + X (:class:`FDataGrid`, optional): fdatagrid with the sample or + samples whose neighbors will be returned. If not provided, + neighbors of each indexed point are returned. In this case, the + query point is not considered its own neighbor. + radius (float, optional): Limiting distance of neighbors to return. + (default is the value passed to the constructor). + return_distance (boolean, optional). Defaults to True. If False, + distances will not be returned + + Returns + (array, shape (n_samples): dist : array of arrays representing the + distances to each point, only present if return_distance=True. + The distance values are computed according to the ``metric`` + constructor parameter. + (array, shape (n_samples,): An array of arrays of indices of the + approximate nearest points from the population matrix that lie + within a ball of size ``radius`` around the query points. + + See also: + kneighbors + + Notes: + + Because the number of neighbors of each point is not necessarily + equal, the results for multiple query points cannot be fit in a + standard data array. + For efficiency, `radius_neighbors` returns arrays of objects, where + each object is a 1D array of indices or distances. + + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.radius_neighbors(X=X, radius=radius, + return_distance=return_distance) + + def radius_neighbors_graph(self, X=None, radius=None, mode='connectivity'): + """Computes the (weighted) graph of Neighbors for points in X + Neighborhoods are restricted the points at a distance lower than + radius. + + Args: + X (:class:`FDataGrid`): The query sample or samples. If not + provided, neighbors of each indexed point are returned. In this + case, the query point is not considered its own neighbor. + radius (float): Radius of neighborhoods. (default is the value + passed to the constructor). + mode ('connectivity' or 'distance', optional): Type of returned + matrix: 'connectivity' will return the connectivity matrix with + ones and zeros, in 'distance' the edges are distance between + points. + + Returns: + sparse matrix in CSR format, shape = [n_samples, n_samples] + A[i, j] is assigned the weight of edge that connects i to j. + + See also: + kneighbors_graph + + Notes: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.radius_neighbors_graph(X=X, radius=radius, + mode=mode) + + +class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): + """ + + + """ + + def __init__(self, n_neighbors=5, radius=1.0, weights='uniform', + algorithm='auto', leaf_size=30, metric=lp_distance, + metric_params=None, outlier_label=None, n_jobs=1): + + + super().__init__(n_neighbors=n_neighbors, radius=radius, + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs) + + self.outlier_label = outlier_label + + def _init_estimator(self, sk_metric): + """Initialize the sklearn nearest neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + return _NearestNeighbors( + n_neighbors=self.n_neighbors, radius=self.radius, + weights=self.weights, algorithm=self.algorithm, + leaf_size=self.leaf_size, metric=sk_metric, + metric_params=self.metric_params, n_jobs=self.n_jobs) + + +class KNeighborsClassifier(NeighborsBase, KNeighborsMixin, ClassifierMixin): + r""" + + + """ + def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1): + """ + + """ + super().__init__(n_neighbors = n_neighbors, + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs) + + def _init_estimator(self, sk_metric): + """Initialize the sklearn K neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + return _KNeighborsClassifier( + n_neighbors=self.n_neighbors, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + + def predict(self, X): + r""" + + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict(X) + + + def predict_proba(self, X): + r""" + + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict_proba(X) + +class RadiusNeighborsClassifier(NeighborsBase, RadiusNeighborsMixin, + ClassifierMixin): + r""" + + + """ + def __init__(self, radius=1.0, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + outlier_label =None, n_jobs=1): + """ + + + """ + super().__init__(radius=radius, weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs) + + self.outlier_label = outlier_label + + def _init_estimator(self, sk_metric): + """Initialize the sklearn radius neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn Radius Neighbors estimator initialized. + + """ + return _RadiusNeighborsClassifier( + radius=self.radius, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + outlier_label=self.outlier_label, n_jobs=self.n_jobs) + + def predict(self, X): + r""" + + + """ + + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict(X) + + + def predict_proba(self, X): + r""" + + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict_proba(X) diff --git a/skfda/ml/classification/__init__.py b/skfda/ml/classification/__init__.py index e69de29bb..8f18a8a33 100644 --- a/skfda/ml/classification/__init__.py +++ b/skfda/ml/classification/__init__.py @@ -0,0 +1,3 @@ + + +from .._neighbors import KNeighborsClassifier, RadiusNeighborsClassifier diff --git a/skfda/ml/regression/__init__.py b/skfda/ml/regression/__init__.py index e69de29bb..8b1378917 100644 --- a/skfda/ml/regression/__init__.py +++ b/skfda/ml/regression/__init__.py @@ -0,0 +1 @@ + From 73d12ab4bbc3e5d1c112c20b84d2db4a9dd7b06c Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 25 May 2019 16:58:00 +0200 Subject: [PATCH 035/222] Neighbors classifiers working --- docs/apilist.rst | 2 +- docs/modules/ml.rst | 15 + ...ml.classification.KNeighborsClassifier.rst | 30 ++ ...fda.ml.classification.NearestCentroids.rst | 27 ++ ...fda.ml.classification.NearestNeighbors.rst | 29 ++ ...assification.RadiusNeighborsClassifier.rst | 30 ++ docs/modules/ml/classification.rst | 18 + skfda/ml/_neighbors.py | 427 +++++++++++++++--- skfda/ml/classification/__init__.py | 3 +- 9 files changed, 513 insertions(+), 68 deletions(-) create mode 100644 docs/modules/ml.rst create mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst create mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst create mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst create mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst create mode 100644 docs/modules/ml/classification.rst diff --git a/docs/apilist.rst b/docs/apilist.rst index c30ccfdad..c49d0852c 100644 --- a/docs/apilist.rst +++ b/docs/apilist.rst @@ -10,4 +10,4 @@ API Reference modules/exploratory modules/datasets modules/misc - + modules/ml diff --git a/docs/modules/ml.rst b/docs/modules/ml.rst new file mode 100644 index 000000000..869700725 --- /dev/null +++ b/docs/modules/ml.rst @@ -0,0 +1,15 @@ +Machine Learning +================ + +Miscelaneus functions and objects. + +Classification +-------------- + +Introduction to classification + +.. toctree:: + :maxdepth: 2 + :caption: Modules: + + ml/classification diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst b/docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst new file mode 100644 index 000000000..0103a1475 --- /dev/null +++ b/docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst @@ -0,0 +1,30 @@ +skfda.ml.classification.KNeighborsClassifier +============================================ + +.. currentmodule:: skfda.ml.classification + +.. autoclass:: KNeighborsClassifier + + + .. automethod:: __init__ + + + .. rubric:: Methods + + .. autosummary:: + + ~KNeighborsClassifier.__init__ + ~KNeighborsClassifier.fit + ~KNeighborsClassifier.get_params + ~KNeighborsClassifier.kneighbors + ~KNeighborsClassifier.kneighbors_graph + ~KNeighborsClassifier.predict + ~KNeighborsClassifier.predict_proba + ~KNeighborsClassifier.score + ~KNeighborsClassifier.set_params + + + + + + \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst b/docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst new file mode 100644 index 000000000..106bbcea0 --- /dev/null +++ b/docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst @@ -0,0 +1,27 @@ +skfda.ml.classification.NearestCentroids +======================================== + +.. currentmodule:: skfda.ml.classification + +.. autoclass:: NearestCentroids + + + .. automethod:: __init__ + + + .. rubric:: Methods + + .. autosummary:: + + ~NearestCentroids.__init__ + ~NearestCentroids.fit + ~NearestCentroids.get_params + ~NearestCentroids.predict + ~NearestCentroids.score + ~NearestCentroids.set_params + + + + + + \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst b/docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst new file mode 100644 index 000000000..d933acdcd --- /dev/null +++ b/docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst @@ -0,0 +1,29 @@ +skfda.ml.classification.NearestNeighbors +======================================== + +.. currentmodule:: skfda.ml.classification + +.. autoclass:: NearestNeighbors + + + .. automethod:: __init__ + + + .. rubric:: Methods + + .. autosummary:: + + ~NearestNeighbors.__init__ + ~NearestNeighbors.fit + ~NearestNeighbors.get_params + ~NearestNeighbors.kneighbors + ~NearestNeighbors.kneighbors_graph + ~NearestNeighbors.radius_neighbors + ~NearestNeighbors.radius_neighbors_graph + ~NearestNeighbors.set_params + + + + + + \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst b/docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst new file mode 100644 index 000000000..c1b0f9c80 --- /dev/null +++ b/docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst @@ -0,0 +1,30 @@ +skfda.ml.classification.RadiusNeighborsClassifier +================================================= + +.. currentmodule:: skfda.ml.classification + +.. autoclass:: RadiusNeighborsClassifier + + + .. automethod:: __init__ + + + .. rubric:: Methods + + .. autosummary:: + + ~RadiusNeighborsClassifier.__init__ + ~RadiusNeighborsClassifier.fit + ~RadiusNeighborsClassifier.get_params + ~RadiusNeighborsClassifier.predict + ~RadiusNeighborsClassifier.predict_proba + ~RadiusNeighborsClassifier.radius_neighbors + ~RadiusNeighborsClassifier.radius_neighbors_graph + ~RadiusNeighborsClassifier.score + ~RadiusNeighborsClassifier.set_params + + + + + + \ No newline at end of file diff --git a/docs/modules/ml/classification.rst b/docs/modules/ml/classification.rst new file mode 100644 index 000000000..7118d1f61 --- /dev/null +++ b/docs/modules/ml/classification.rst @@ -0,0 +1,18 @@ +Classification +============== + +Header Classification + + +Nearest Neighbors +----------------- + +Introduction to nearest neighbors + +.. autosummary:: + :toctree: autosummary + + skfda.ml.classification.KNeighborsClassifier + skfda.ml.classification.RadiusNeighborsClassifier + skfda.ml.classification.NearestCentroids + skfda.ml.classification.NearestNeighbors diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index 88c9b34dd..6bd333641 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -4,6 +4,8 @@ from sklearn.base import BaseEstimator, ClassifierMixin from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted +from sklearn.utils.multiclass import check_classification_targets +from sklearn.preprocessing import LabelEncoder # Sklearn classes to be wrapped from sklearn.neighbors import NearestNeighbors as _NearestNeighbors @@ -12,7 +14,9 @@ _RadiusNeighborsClassifier) from .. import FDataGrid -from ..misc.metrics import lp_distance +from ..misc.metrics import lp_distance, pairwise_distance +from ..exploratory.stats import mean + def _to_multivariate(fdatagrid): r"""Returns the data matrix of a fdatagrid in flatten form compatible with @@ -79,7 +83,7 @@ def _to_sklearn_metric(metric, sample_points, check=True): 1.0 Creation of the sklearn-style metric. - >>> sklearn_lp_distance = _to_sklearn_metric(lp_distance, x) + >>> sklearn_lp_distance = _to_sklearn_metric(lp_distance, [x]) >>> sklearn_lp_distance(np.ones(len(x)), np.zeros(len(x))).round(2) 1.0 @@ -120,7 +124,6 @@ def __init__(self, n_neighbors=None, radius=None, self.metric_params = metric_params self.n_jobs = n_jobs - @abstractmethod def _init_estimator(self, sk_metric): """Initializes the estimator returned by :meth:`_sklearn_neighbors`.""" @@ -153,22 +156,19 @@ def fit(self, X, y): sk_metric = _to_sklearn_metric(self.metric, self._sample_points) self.estimator_ = self._init_estimator(sk_metric) - print(self.estimator_) self.estimator_.fit(self._transform_to_multivariate(X), y) return self - def _check_is_fitted(self): """Check if the estimator is fitted. - Raise: + Raises: NotFittedError: If the estimator is not fitted. """ sklearn_check_is_fitted(self, ['estimator_']) - def _transform_to_multivariate(self, X): """Transform the input data to array form. If the metric is precomputed it is not transformet. @@ -258,6 +258,7 @@ def kneighbors_graph(self, X=None, n_neighbors=None, mode='connectivity'): return self.estimator_.kneighbors_graph(X, n_neighbors, mode) + class RadiusNeighborsMixin: """Mixin Class for Raius Neighbors""" @@ -346,23 +347,116 @@ def radius_neighbors_graph(self, X=None, radius=None, mode='connectivity'): mode=mode) -class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): - """ +class NeighborsClassifierMixin: + """Mixin class for classifiers based in nearest neighbors""" + def predict(self, X): + """Predict the class labels for the provided data. - """ + Args: + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. - def __init__(self, n_neighbors=5, radius=1.0, weights='uniform', - algorithm='auto', leaf_size=30, metric=lp_distance, - metric_params=None, outlier_label=None, n_jobs=1): + Returns: + (np.array): y : array of shape [n_samples] or + [n_samples, n_outputs] with class labels for each data sample. - super().__init__(n_neighbors=n_neighbors, radius=radius, - weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs) + Notes: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. - self.outlier_label = outlier_label + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict(X) + + def predict_proba(self, X): + """Return probability estimates for the test data X. + + Args: + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + Returns + p : array of shape = [n_samples, n_classes], or a list of n_outputs + of such arrays if n_outputs > 1. + The class probabilities of the input samples. Classes are + ordered by lexicographic order. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict_proba(X) + + +class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): + """Unsupervised learner for implementing neighbor searches. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm based on + the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree or KDTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + Doesn't affect :meth:`fit` method. + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsRegressor + RadiusNeighborsRegressor + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.KNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1): + """Initialize the nearest neighbors searcher.""" + + super().__init__(n_neighbors=n_neighbors, radius=radius, + algorithm=algorithm, leaf_size=leaf_size, + metric=metric, metric_params=metric_params, + n_jobs=n_jobs) def _init_estimator(self, sk_metric): """Initialize the sklearn nearest neighbors estimator. @@ -378,23 +472,115 @@ def _init_estimator(self, sk_metric): """ return _NearestNeighbors( n_neighbors=self.n_neighbors, radius=self.radius, - weights=self.weights, algorithm=self.algorithm, - leaf_size=self.leaf_size, metric=sk_metric, - metric_params=self.metric_params, n_jobs=self.n_jobs) + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + + def fit(self, X): + """Fit the model using X as training data. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + + Note: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + # If metric is precomputed no different with the Sklearn stimator + if self.metric == 'precomputed': + self.estimator_ = self._init_estimator(self.metric) + self.estimator_.fit(X) + else: + self._sample_points = X.sample_points + self._shape = X.data_matrix.shape[1:] + # Constructs sklearn metric to manage vector instead of FDatagrids + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) -class KNeighborsClassifier(NeighborsBase, KNeighborsMixin, ClassifierMixin): - r""" + self.estimator_ = self._init_estimator(sk_metric) + self.estimator_.fit(self._transform_to_multivariate(X)) + + return self +class KNeighborsClassifier(NeighborsBase, KNeighborsMixin, ClassifierMixin, + NeighborsClassifierMixin): + """Classifier implementing the k-nearest neighbors vote. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + weights : str or callable, optional (default = 'uniform') + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm based on + the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree or KDTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + Doesn't affect :meth:`fit` method. + See also + -------- + RadiusNeighborsClassifier + KNeighborsRegressor + RadiusNeighborsRegressor + NearestNeighbors + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.KNeighborsClassifier`. + + .. warning:: + Regarding the Nearest Neighbors algorithms, if it is found that two + neighbors, neighbor `k+1` and `k`, have identical distances + but different labels, the results will depend on the ordering of the + training data. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + """ + def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, n_jobs=1): - """ + """Initialize the classifier.""" - """ - super().__init__(n_neighbors = n_neighbors, + super().__init__(n_neighbors=n_neighbors, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs) @@ -417,42 +603,82 @@ def _init_estimator(self, sk_metric): metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) - def predict(self, X): - r""" - - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.predict(X) - - - def predict_proba(self, X): - r""" - - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.predict_proba(X) class RadiusNeighborsClassifier(NeighborsBase, RadiusNeighborsMixin, - ClassifierMixin): - r""" - + ClassifierMixin, NeighborsClassifierMixin): + """Classifier implementing a vote among neighbors within a given radius + + Parameters + ---------- + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + outlier_label : int, optional (default = None) + Label, which is given for outlier samples (samples with no + neighbors on given radius). + If set to None, ValueError is raised, when outlier is detected. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + + See also + -------- + KNeighborsClassifier + RadiusNeighborsRegressor + KNeighborsRegressor + NearestNeighbors + + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm """ + def __init__(self, radius=1.0, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, - outlier_label =None, n_jobs=1): - """ - + outlier_label=None, n_jobs=1): + """Initialize the classifier.""" - """ super().__init__(radius=radius, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs) @@ -477,26 +703,95 @@ def _init_estimator(self, sk_metric): metric=sk_metric, metric_params=self.metric_params, outlier_label=self.outlier_label, n_jobs=self.n_jobs) - def predict(self, X): - r""" +class NearestCentroids(BaseEstimator, ClassifierMixin): + """Nearest centroid classifier for functional data. + + Each class is represented by its centroid, with test samples classified to + the class with the nearest centroid. + + Parameters + ---------- + metric : callable, (default + :func:`lp_distance `) + The metric to use when calculating distance between test samples and + centroids. See the documentation of the metrics module + for a list of available metrics. Defaults used L2 distance. + mean: callable, (default :func:`mean `) + The centroids for the samples corresponding to each class is the + point from which the sum of the distances (according to the metric) + of all samples that belong to that particular class are minimized. + By default it is used the usual mean, which minimizes the sum of L2 + distance. This parameter allows change the centroid constructor. The + function must accept a :class:`FData` with the samples of one class + and return a :class:`FData` object with only one sample representing + the centroid. + Attributes + ---------- + centroids_ : :class:`FDataGrid` + FDatagrid containing the centroid of each class + See also + -------- + KNeighborsClassifier + RadiusNeighborsRegressor + KNeighborsRegressor + NearestNeighbors + + """ + + def __init__(self, metric=lp_distance, mean=mean): + """Initialize the classifier.""" + self.metric = metric + self.mean = mean + + def fit(self, X, y): + """Fit the model using X as training data and y as target values. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + y (array-like or sparse matrix): Target values of + shape = [n_samples] or [n_samples, n_outputs]. """ + if self.metric == 'precomputed': + raise ValueError("Precomputed is not supported.") - self._check_is_fitted() + self._pairwise_distance = pairwise_distance(self.metric) - X = self._transform_to_multivariate(X) + check_classification_targets(y) - return self.estimator_.predict(X) + le = LabelEncoder() + y_ind = le.fit_transform(y) + self.classes_ = classes = le.classes_ + n_classes = classes.size + if n_classes < 2: + raise ValueError(f'The number of classes has to be greater than' + f' one; got {n_classes} class') + self.centroids_ = self.mean(X[y_ind == 0]) - def predict_proba(self, X): - r""" + # This could be changed to allow all the concatenation at the same time + # After merge image-operations + for cur_class in range(1, n_classes): + center_mask = y_ind == cur_class + centroid = self.mean(X[center_mask]) + self.centroids_ = self.centroids_.concatenate(centroid) + def predict(self, X): + """Predict the class labels for the provided data. - """ - self._check_is_fitted() + Args: + X (:class:`FDataGrid`): FDataGrid with the test samples. - X = self._transform_to_multivariate(X) + Returns: - return self.estimator_.predict_proba(X) + (np.array): y : array of shape [n_samples] or + [n_samples, n_outputs] with class labels for each data sample. + + """ + sklearn_check_is_fitted(self, 'centroids_') + + return self.classes_[self._pairwise_distance( + X, self.centroids_).argmin(axis=1)] diff --git a/skfda/ml/classification/__init__.py b/skfda/ml/classification/__init__.py index 8f18a8a33..76863d9bb 100644 --- a/skfda/ml/classification/__init__.py +++ b/skfda/ml/classification/__init__.py @@ -1,3 +1,4 @@ -from .._neighbors import KNeighborsClassifier, RadiusNeighborsClassifier +from .._neighbors import (KNeighborsClassifier, RadiusNeighborsClassifier, + NearestNeighbors, NearestCentroids) From 4a82918f90b392d8e98a6fb11f2212a7cf7c0516 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 25 May 2019 17:45:55 +0200 Subject: [PATCH 036/222] Scalar regression based in nearest neighbors --- docs/modules/ml.rst | 1 + ...l.regression.KNeighborsScalarRegressor.rst | 29 ++ ...ression.RadiusNeighborsScalarRegressor.rst | 29 ++ docs/modules/ml/regression.rst | 16 ++ skfda/misc/metrics.py | 41 +-- skfda/ml/_neighbors.py | 266 ++++++++++++++++-- skfda/ml/regression/__init__.py | 2 + 7 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst create mode 100644 docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst create mode 100644 docs/modules/ml/regression.rst diff --git a/docs/modules/ml.rst b/docs/modules/ml.rst index 869700725..68e259369 100644 --- a/docs/modules/ml.rst +++ b/docs/modules/ml.rst @@ -13,3 +13,4 @@ Introduction to classification :caption: Modules: ml/classification + ml/regression diff --git a/docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst b/docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst new file mode 100644 index 000000000..8f0782d4e --- /dev/null +++ b/docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst @@ -0,0 +1,29 @@ +skfda.ml.regression.KNeighborsScalarRegressor +============================================= + +.. currentmodule:: skfda.ml.regression + +.. autoclass:: KNeighborsScalarRegressor + + + .. automethod:: __init__ + + + .. rubric:: Methods + + .. autosummary:: + + ~KNeighborsScalarRegressor.__init__ + ~KNeighborsScalarRegressor.fit + ~KNeighborsScalarRegressor.get_params + ~KNeighborsScalarRegressor.kneighbors + ~KNeighborsScalarRegressor.kneighbors_graph + ~KNeighborsScalarRegressor.predict + ~KNeighborsScalarRegressor.score + ~KNeighborsScalarRegressor.set_params + + + + + + \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst b/docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst new file mode 100644 index 000000000..827b2bc24 --- /dev/null +++ b/docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst @@ -0,0 +1,29 @@ +skfda.ml.regression.RadiusNeighborsScalarRegressor +================================================== + +.. currentmodule:: skfda.ml.regression + +.. autoclass:: RadiusNeighborsScalarRegressor + + + .. automethod:: __init__ + + + .. rubric:: Methods + + .. autosummary:: + + ~RadiusNeighborsScalarRegressor.__init__ + ~RadiusNeighborsScalarRegressor.fit + ~RadiusNeighborsScalarRegressor.get_params + ~RadiusNeighborsScalarRegressor.predict + ~RadiusNeighborsScalarRegressor.radius_neighbors + ~RadiusNeighborsScalarRegressor.radius_neighbors_graph + ~RadiusNeighborsScalarRegressor.score + ~RadiusNeighborsScalarRegressor.set_params + + + + + + \ No newline at end of file diff --git a/docs/modules/ml/regression.rst b/docs/modules/ml/regression.rst new file mode 100644 index 000000000..6a90d2d82 --- /dev/null +++ b/docs/modules/ml/regression.rst @@ -0,0 +1,16 @@ +Regression +========== + +Header regression + + +Nearest Neighbors +----------------- + +Introduction to nearest neighbors regression + +.. autosummary:: + :toctree: autosummary + + skfda.ml.regression.KNeighborsScalarRegressor + skfda.ml.regression.RadiusNeighborsScalarRegressor diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 8a32816f1..7089a40e7 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -10,7 +10,7 @@ elastic_registration_warping) -def _cast_to_grid(fdata1, fdata2, eval_points=None): +def _cast_to_grid(fdata1, fdata2, eval_points=None, check=True, **kwargs): """Checks if the fdatas passed as argument are unidimensional and compatible and converts them to FDatagrid to compute their distances. @@ -23,6 +23,10 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None): tuple: Tuple with two :obj:`FDataGrid` with the same sample points. """ + # Dont perform any check + if not check: + return fdata1, fdata2 + # To allow use numpy arrays internally if (not isinstance(fdata1, FData) and not isinstance(fdata2, FData) and eval_points is not None): @@ -189,9 +193,7 @@ def pairwise_distance(distance, **kwargs): """ def pairwise(fdata1, fdata2): - # Checks - if not numpy.array_equal(fdata1.domain_range, fdata2.domain_range): - raise ValueError("Domain ranges for both objects must be equal") + fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, **kwargs) # Creates an empty matrix with the desired size to store the results. matrix = numpy.empty((fdata1.nsamples, fdata2.nsamples)) @@ -199,7 +201,8 @@ def pairwise(fdata1, fdata2): # Iterates over the different samples of both objects. for i in range(fdata1.nsamples): for j in range(fdata2.nsamples): - matrix[i, j] = distance(fdata1[i], fdata2[j], **kwargs) + matrix[i, j] = distance(fdata1[i], fdata2[j], check=False, + **kwargs) # Computes the metric between all piars of x and y. return matrix @@ -299,7 +302,7 @@ def norm_lp(fdatagrid, p=2, p2=2): return res -def lp_distance(fdata1, fdata2, p=2, *, eval_points=None): +def lp_distance(fdata1, fdata2, p=2, *, eval_points=None, check=True): r"""Lp distance for FDataGrid objects. Calculates the distance between all possible pairs of one sample of @@ -339,14 +342,13 @@ def lp_distance(fdata1, fdata2, p=2, *, eval_points=None): """ # Checks - fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points) + fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, + check=check) return norm_lp(fdata1 - fdata2, p=p) - - -def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): +def fisher_rao_distance(fdata1, fdata2, *, eval_points=None, check=True): """Compute the Fisher-Rao distance btween two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let @@ -383,7 +385,8 @@ def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): """ - fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points) + fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, + check=check) # Both should have the same sample points eval_points_normalized = _normalize_scale(fdata1.sample_points[0]) @@ -400,7 +403,8 @@ def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): # Return the L2 distance of the SRSF return lp_distance(fdata1_srsf, fdata2_srsf, p=2) -def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): +def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, + **kwargs): """Compute the amplitude distance between two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let @@ -449,7 +453,8 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): (pp. 107-109). Springer. """ - fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points) + fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, + check=check) # Both should have the same sample points eval_points_normalized = _normalize_scale(fdata1.sample_points[0]) @@ -488,7 +493,8 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): return distance -def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): +def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, + **kwargs): """Compute the amplitude distance btween two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let @@ -528,7 +534,8 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): """ - fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points) + fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, + check=check) # Rescale in (0,1) eval_points_normalized = _normalize_scale(fdata1.sample_points[0]) @@ -554,7 +561,7 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): return numpy.arccos(d) -def warping_distance(warping1, warping2, *, eval_points=None): +def warping_distance(warping1, warping2, *, eval_points=None, check=True): """Compute the distance between warpings functions. Let :math:`\\gamma_i` and :math:`\\gamma_j` be two warpings, defined in @@ -591,7 +598,7 @@ def warping_distance(warping1, warping2, *, eval_points=None): """ warping1, warping2 = _cast_to_grid(warping1, warping2, - eval_points=eval_points) + eval_points=eval_points, check=check) # Normalization of warping to (0,1)x(0,1) warping1 = normalize_warping(warping1, (0,1)) diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index 6bd333641..f5920fe13 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -2,7 +2,7 @@ from abc import ABCMeta, abstractmethod, abstractproperty -from sklearn.base import BaseEstimator, ClassifierMixin +from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted from sklearn.utils.multiclass import check_classification_targets from sklearn.preprocessing import LabelEncoder @@ -12,6 +12,9 @@ from sklearn.neighbors import KNeighborsClassifier as _KNeighborsClassifier from sklearn.neighbors import (RadiusNeighborsClassifier as _RadiusNeighborsClassifier) +from sklearn.neighbors import KNeighborsRegressor as _KNeighborsRegressor +from sklearn.neighbors import (RadiusNeighborsRegressor as + _RadiusNeighborsRegressor) from .. import FDataGrid from ..misc.metrics import lp_distance, pairwise_distance @@ -50,7 +53,7 @@ def _from_multivariate(data_matrix, sample_points, shape, **kwargs): return FDataGrid(data_matrix.reshape(shape), sample_points, **kwargs) -def _to_sklearn_metric(metric, sample_points, check=True): +def _to_sklearn_metric(metric, sample_points): r"""Transform a metric between FDatagrid in a sklearn compatible one. Given a metric between FDatagrids returns a compatible metric used to @@ -91,17 +94,11 @@ def _to_sklearn_metric(metric, sample_points, check=True): # Shape -> (Nsamples = 1, domain_dims...., image_dimension (-1)) shape = [1] + [len(axis) for axis in sample_points] + [-1] - if check: - def sklearn_metric(x, y, **kwargs): + def sklearn_metric(x, y, check=True, **kwargs): - return metric(_from_multivariate(x, sample_points, shape), - _from_multivariate(y, sample_points, shape), **kwargs) - else: - def sklearn_metric(x, y, **kwargs): - - return metric(_from_multivariate(x, sample_points, shape), - _from_multivariate(y, sample_points, shape), - check=False, **kwargs) + return metric(_from_multivariate(x, sample_points, shape), + _from_multivariate(y, sample_points, shape), + check=check, **kwargs) return sklearn_metric @@ -394,6 +391,32 @@ def predict_proba(self, X): return self.estimator_.predict_proba(X) +class NeighborsScalarRegresorMixin: + """Mixin class for scalar regressor based in nearest neighbors""" + + def predict(self, X): + """Predict the target for the provided data + Parameters + ---------- + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + Returns + ------- + y : array of int, shape = [n_samples] or [n_samples, n_outputs] + Target values + Notes + ----- + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict(X) + class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): """Unsupervised learner for implementing neighbor searches. @@ -434,8 +457,9 @@ class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): -------- KNeighborsClassifier RadiusNeighborsClassifier - KNeighborsRegressor - RadiusNeighborsRegressor + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor + NearestCentroids Notes ----- See Nearest Neighbors in the sklearn online documentation for a discussion @@ -554,9 +578,10 @@ class KNeighborsClassifier(NeighborsBase, KNeighborsMixin, ClassifierMixin, See also -------- RadiusNeighborsClassifier - KNeighborsRegressor - RadiusNeighborsRegressor + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor NearestNeighbors + NearestCentroids Notes ----- See Nearest Neighbors in the sklearn online documentation for a discussion @@ -658,9 +683,10 @@ class RadiusNeighborsClassifier(NeighborsBase, RadiusNeighborsMixin, See also -------- KNeighborsClassifier - RadiusNeighborsRegressor - KNeighborsRegressor + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor NearestNeighbors + NearestCentroids Notes ----- @@ -733,12 +759,12 @@ class NearestCentroids(BaseEstimator, ClassifierMixin): See also -------- KNeighborsClassifier - RadiusNeighborsRegressor - KNeighborsRegressor + RadiusNeighborsClassifier + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor NearestNeighbors """ - def __init__(self, metric=lp_distance, mean=mean): """Initialize the classifier.""" self.metric = metric @@ -795,3 +821,201 @@ def predict(self, X): return self.classes_[self._pairwise_distance( X, self.centroids_).argmin(axis=1)] + +class KNeighborsScalarRegressor(NeighborsBase, KNeighborsMixin, RegressorMixin, + NeighborsScalarRegresorMixin): + """Regression based on k-nearest neighbors with scalar response. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + weights : str or callable, optional (default = 'uniform') + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm based on + the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree or KDTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + Doesn't affect :meth:`fit` method. + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + RadiusNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn regressor + `sklearn.neighbors.KNeighborsRegressor`. + + .. warning:: + Regarding the Nearest Neighbors algorithms, if it is found that two + neighbors, neighbor `k+1` and `k`, have identical distances + but different labels, the results will depend on the ordering of the + training data. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1): + """Initialize the classifier.""" + + super().__init__(n_neighbors=n_neighbors, + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs) + + def _init_estimator(self, sk_metric): + """Initialize the sklearn K neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + return _KNeighborsRegressor( + n_neighbors=self.n_neighbors, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + +class RadiusNeighborsScalarRegressor(NeighborsBase, RadiusNeighborsMixin, + RegressorMixin, + NeighborsScalarRegresorMixin): + """Scalar regression based on neighbors within a fixed radius. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, radius=1.0, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1): + """Initialize the classifier.""" + + super().__init__(radius=radius, weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs) + + + def _init_estimator(self, sk_metric): + """Initialize the sklearn radius neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn Radius Neighbors estimator initialized. + + """ + return _RadiusNeighborsRegressor( + radius=self.radius, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) diff --git a/skfda/ml/regression/__init__.py b/skfda/ml/regression/__init__.py index 8b1378917..6cb7fd409 100644 --- a/skfda/ml/regression/__init__.py +++ b/skfda/ml/regression/__init__.py @@ -1 +1,3 @@ +from .._neighbors import (KNeighborsScalarRegressor, + RadiusNeighborsScalarRegressor) From 83541479028bc5de23d6d828ea98cf5df9865166 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 25 May 2019 17:54:25 +0200 Subject: [PATCH 037/222] gitignore autosummary ml module --- docs/modules/ml/.gitignore | 1 + ...ml.classification.KNeighborsClassifier.rst | 30 ------------------- ...fda.ml.classification.NearestCentroids.rst | 27 ----------------- ...fda.ml.classification.NearestNeighbors.rst | 29 ------------------ ...assification.RadiusNeighborsClassifier.rst | 30 ------------------- ...l.regression.KNeighborsScalarRegressor.rst | 29 ------------------ ...ression.RadiusNeighborsScalarRegressor.rst | 29 ------------------ 7 files changed, 1 insertion(+), 174 deletions(-) create mode 100644 docs/modules/ml/.gitignore delete mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst delete mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst delete mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst delete mode 100644 docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst delete mode 100644 docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst delete mode 100644 docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst diff --git a/docs/modules/ml/.gitignore b/docs/modules/ml/.gitignore new file mode 100644 index 000000000..beebbea8e --- /dev/null +++ b/docs/modules/ml/.gitignore @@ -0,0 +1 @@ +/autosummary/ diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst b/docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst deleted file mode 100644 index 0103a1475..000000000 --- a/docs/modules/ml/autosummary/skfda.ml.classification.KNeighborsClassifier.rst +++ /dev/null @@ -1,30 +0,0 @@ -skfda.ml.classification.KNeighborsClassifier -============================================ - -.. currentmodule:: skfda.ml.classification - -.. autoclass:: KNeighborsClassifier - - - .. automethod:: __init__ - - - .. rubric:: Methods - - .. autosummary:: - - ~KNeighborsClassifier.__init__ - ~KNeighborsClassifier.fit - ~KNeighborsClassifier.get_params - ~KNeighborsClassifier.kneighbors - ~KNeighborsClassifier.kneighbors_graph - ~KNeighborsClassifier.predict - ~KNeighborsClassifier.predict_proba - ~KNeighborsClassifier.score - ~KNeighborsClassifier.set_params - - - - - - \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst b/docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst deleted file mode 100644 index 106bbcea0..000000000 --- a/docs/modules/ml/autosummary/skfda.ml.classification.NearestCentroids.rst +++ /dev/null @@ -1,27 +0,0 @@ -skfda.ml.classification.NearestCentroids -======================================== - -.. currentmodule:: skfda.ml.classification - -.. autoclass:: NearestCentroids - - - .. automethod:: __init__ - - - .. rubric:: Methods - - .. autosummary:: - - ~NearestCentroids.__init__ - ~NearestCentroids.fit - ~NearestCentroids.get_params - ~NearestCentroids.predict - ~NearestCentroids.score - ~NearestCentroids.set_params - - - - - - \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst b/docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst deleted file mode 100644 index d933acdcd..000000000 --- a/docs/modules/ml/autosummary/skfda.ml.classification.NearestNeighbors.rst +++ /dev/null @@ -1,29 +0,0 @@ -skfda.ml.classification.NearestNeighbors -======================================== - -.. currentmodule:: skfda.ml.classification - -.. autoclass:: NearestNeighbors - - - .. automethod:: __init__ - - - .. rubric:: Methods - - .. autosummary:: - - ~NearestNeighbors.__init__ - ~NearestNeighbors.fit - ~NearestNeighbors.get_params - ~NearestNeighbors.kneighbors - ~NearestNeighbors.kneighbors_graph - ~NearestNeighbors.radius_neighbors - ~NearestNeighbors.radius_neighbors_graph - ~NearestNeighbors.set_params - - - - - - \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst b/docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst deleted file mode 100644 index c1b0f9c80..000000000 --- a/docs/modules/ml/autosummary/skfda.ml.classification.RadiusNeighborsClassifier.rst +++ /dev/null @@ -1,30 +0,0 @@ -skfda.ml.classification.RadiusNeighborsClassifier -================================================= - -.. currentmodule:: skfda.ml.classification - -.. autoclass:: RadiusNeighborsClassifier - - - .. automethod:: __init__ - - - .. rubric:: Methods - - .. autosummary:: - - ~RadiusNeighborsClassifier.__init__ - ~RadiusNeighborsClassifier.fit - ~RadiusNeighborsClassifier.get_params - ~RadiusNeighborsClassifier.predict - ~RadiusNeighborsClassifier.predict_proba - ~RadiusNeighborsClassifier.radius_neighbors - ~RadiusNeighborsClassifier.radius_neighbors_graph - ~RadiusNeighborsClassifier.score - ~RadiusNeighborsClassifier.set_params - - - - - - \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst b/docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst deleted file mode 100644 index 8f0782d4e..000000000 --- a/docs/modules/ml/autosummary/skfda.ml.regression.KNeighborsScalarRegressor.rst +++ /dev/null @@ -1,29 +0,0 @@ -skfda.ml.regression.KNeighborsScalarRegressor -============================================= - -.. currentmodule:: skfda.ml.regression - -.. autoclass:: KNeighborsScalarRegressor - - - .. automethod:: __init__ - - - .. rubric:: Methods - - .. autosummary:: - - ~KNeighborsScalarRegressor.__init__ - ~KNeighborsScalarRegressor.fit - ~KNeighborsScalarRegressor.get_params - ~KNeighborsScalarRegressor.kneighbors - ~KNeighborsScalarRegressor.kneighbors_graph - ~KNeighborsScalarRegressor.predict - ~KNeighborsScalarRegressor.score - ~KNeighborsScalarRegressor.set_params - - - - - - \ No newline at end of file diff --git a/docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst b/docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst deleted file mode 100644 index 827b2bc24..000000000 --- a/docs/modules/ml/autosummary/skfda.ml.regression.RadiusNeighborsScalarRegressor.rst +++ /dev/null @@ -1,29 +0,0 @@ -skfda.ml.regression.RadiusNeighborsScalarRegressor -================================================== - -.. currentmodule:: skfda.ml.regression - -.. autoclass:: RadiusNeighborsScalarRegressor - - - .. automethod:: __init__ - - - .. rubric:: Methods - - .. autosummary:: - - ~RadiusNeighborsScalarRegressor.__init__ - ~RadiusNeighborsScalarRegressor.fit - ~RadiusNeighborsScalarRegressor.get_params - ~RadiusNeighborsScalarRegressor.predict - ~RadiusNeighborsScalarRegressor.radius_neighbors - ~RadiusNeighborsScalarRegressor.radius_neighbors_graph - ~RadiusNeighborsScalarRegressor.score - ~RadiusNeighborsScalarRegressor.set_params - - - - - - \ No newline at end of file From 9acbbe0ee98187f938c3b19aa2832beaa57a7d58 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 25 May 2019 21:01:16 +0200 Subject: [PATCH 038/222] Test of neighbors estimators --- skfda/ml/_neighbors.py | 40 +++++------ tests/test_neighbors.py | 142 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 20 deletions(-) create mode 100644 tests/test_neighbors.py diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index f5920fe13..fb19f89f2 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -371,26 +371,6 @@ def predict(self, X): return self.estimator_.predict(X) - def predict_proba(self, X): - """Return probability estimates for the test data X. - - Args: - X (:class:`FDataGrid` or array-like): FDataGrid with the test - samples or array (n_query, n_indexed) if metric == - 'precomputed'. - Returns - p : array of shape = [n_samples, n_classes], or a list of n_outputs - of such arrays if n_outputs > 1. - The class probabilities of the input samples. Classes are - ordered by lexicographic order. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.predict_proba(X) - class NeighborsScalarRegresorMixin: """Mixin class for scalar regressor based in nearest neighbors""" @@ -628,6 +608,26 @@ def _init_estimator(self, sk_metric): metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) + def predict_proba(self, X): + """Return probability estimates for the test data X. + + Args: + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + Returns + p : array of shape = [n_samples, n_classes], or a list of n_outputs + of such arrays if n_outputs > 1. + The class probabilities of the input samples. Classes are + ordered by lexicographic order. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict_proba(X) + class RadiusNeighborsClassifier(NeighborsBase, RadiusNeighborsMixin, ClassifierMixin, NeighborsClassifierMixin): diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py new file mode 100644 index 000000000..158da120a --- /dev/null +++ b/tests/test_neighbors.py @@ -0,0 +1,142 @@ +"""Test neighbors classifiers and regressors""" + +import unittest + +import numpy as np +from skfda.datasets import make_multimodal_samples + +from skfda.ml.classification import (KNeighborsClassifier, + RadiusNeighborsClassifier, + NearestCentroids, + NearestNeighbors) + +from skfda.ml.regression import (KNeighborsScalarRegressor, + RadiusNeighborsScalarRegressor) +from skfda.misc.metrics import lp_distance, lp_distance + +class TestNeighbors(unittest.TestCase): + + def setUp(self): + """Creates test data""" + random_state = np.random.RandomState(0) + modes_location = np.concatenate((random_state.normal(-.3, .04, size=15), + random_state.normal(.3, .04, size=15))) + + idx = np.arange(30) + random_state.shuffle(idx) + + + modes_location = modes_location[idx] + self.modes_location = modes_location + self.y = np.array(15*[0] + 15*[1])[idx] + + self.X = make_multimodal_samples(n_samples=30, + modes_location=modes_location, + noise=.05, + random_state=random_state) + + self.probs = np.array(15*[[1., 0.]] + 15*[[0., 1.]])[idx] + + def test_predict_classifier(self): + """Tests predict for neighbors classifier""" + + for neigh in (KNeighborsClassifier(), + RadiusNeighborsClassifier(radius=.1), + NearestCentroids()): + + neigh.fit(self.X, self.y) + pred = neigh.predict(self.X) + np.testing.assert_array_equal(pred, self.y, + err_msg=f"fail in {type(neigh)}") + + def test_predict_proba_classifier(self): + """Tests predict proba for k neighbors classifier""" + + neigh = KNeighborsClassifier() + + neigh.fit(self.X, self.y) + probs = neigh.predict_proba(self.X) + + np.testing.assert_array_almost_equal(probs, self.probs) + + def test_predict_regressor(self): + """Test scalar regression, predics mode location""" + + #Dummy test, with weight = distance, only the sample with distance 0 + # will be returned, obtaining the exact location + knnr = KNeighborsScalarRegressor(weights='distance') + rnnr = RadiusNeighborsScalarRegressor(weights='distance', radius=.1) + + + knnr.fit(self.X, self.modes_location) + rnnr.fit(self.X, self.modes_location) + + np.testing.assert_array_almost_equal(knnr.predict(self.X), + self.modes_location) + np.testing.assert_array_almost_equal(rnnr.predict(self.X), + self.modes_location) + + + def test_kneighbors(self): + + nn = NearestNeighbors() + nn.fit(self.X) + + knn = KNeighborsClassifier() + knn.fit(self.X, self.y) + + knnr = KNeighborsScalarRegressor() + knnr.fit(self.X, self.modes_location) + + for neigh in [nn, knn, knnr]: + + dist, links = neigh.kneighbors(self.X[:4]) + + np.testing.assert_array_equal(links, [[ 0, 7, 21, 23, 15], + [ 1, 12, 19, 18, 17], + [ 2, 17, 22, 27, 26], + [ 3, 4, 9, 5, 25]]) + + dist_kneigh = lp_distance(self.X[0], self.X[7]) + + np.testing.assert_array_almost_equal(dist[0,1], dist_kneigh) + + graph = neigh.kneighbors_graph(self.X[:4]) + + for i in range(30): + self.assertEqual(graph[0, i] == 1.0, i in links[0]) + self.assertEqual(graph[0, i] == 0.0, i not in links[0]) + + def test_radius_neighbors(self): + """Test query with radius""" + nn = NearestNeighbors(radius=.1) + nn.fit(self.X) + + knn = RadiusNeighborsClassifier(radius=.1) + knn.fit(self.X, self.y) + + knnr = RadiusNeighborsScalarRegressor(radius=.1) + knnr.fit(self.X, self.modes_location) + + for neigh in [nn, knn, knnr]: + + dist, links = neigh.radius_neighbors(self.X[:4]) + + np.testing.assert_array_equal(links[0], np.array([0, 7])) + np.testing.assert_array_equal(links[1], np.array([1])) + np.testing.assert_array_equal(links[2], np.array([ 2, 17, 22, 27])) + np.testing.assert_array_equal(links[3], np.array([3, 4, 9])) + + dist_kneigh = lp_distance(self.X[0], self.X[7]) + + np.testing.assert_array_almost_equal(dist[0][1], dist_kneigh) + + graph = neigh.radius_neighbors_graph(self.X[:4]) + + for i in range(30): + self.assertEqual(graph[0, i] == 1.0, i in links[0]) + self.assertEqual(graph[0, i] == 0.0, i not in links[0]) + +if __name__ == '__main__': + print() + unittest.main() From 41510addc57c587f3833199c75bc35c669839564 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sun, 26 May 2019 18:15:43 +0200 Subject: [PATCH 039/222] Functional regressors and structure of examples --- docs/modules/ml/regression.rst | 2 + examples/plot_k_neighbors_classification.py | 149 ++++ .../plot_radius_neighbors_classification.py | 171 ++++ skfda/misc/metrics.py | 16 +- skfda/ml/_neighbors.py | 792 ++++++++++++++++-- skfda/ml/regression/__init__.py | 4 +- 6 files changed, 1056 insertions(+), 78 deletions(-) create mode 100644 examples/plot_k_neighbors_classification.py create mode 100644 examples/plot_radius_neighbors_classification.py diff --git a/docs/modules/ml/regression.rst b/docs/modules/ml/regression.rst index 6a90d2d82..536a79e9e 100644 --- a/docs/modules/ml/regression.rst +++ b/docs/modules/ml/regression.rst @@ -14,3 +14,5 @@ Introduction to nearest neighbors regression skfda.ml.regression.KNeighborsScalarRegressor skfda.ml.regression.RadiusNeighborsScalarRegressor + skfda.ml.regression.KNeighborsFunctionalRegressor + skfda.ml.regression.RadiusNeighborsFunctionalRegressor diff --git a/examples/plot_k_neighbors_classification.py b/examples/plot_k_neighbors_classification.py new file mode 100644 index 000000000..6c1cec08f --- /dev/null +++ b/examples/plot_k_neighbors_classification.py @@ -0,0 +1,149 @@ +""" +K-nearest neighbors classification +================================== + +Shows the usage of the k-nearest neighbors classifier. +""" + +# Author: Pablo Marcos Manchón +# License: MIT + +import skfda +import numpy as np +import matplotlib.pyplot as plt +from sklearn.model_selection import train_test_split, GridSearchCV, KFold +from skfda.ml.classification import KNeighborsClassifier + + +################################################################################ +# +# Text +# +# + +data = skfda.datasets.fetch_growth() +X = data['data'] +y = data['target'] + +X[y==0].plot(color='C0') +X[y==1].plot(color='C1') + +################################################################################ +# +# +# +# Text + +print(y) + +################################################################################ +# +# +# +# Text + +X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=0) + + +################################################################################ +# +# +# +# Text + +knn = KNeighborsClassifier() +knn.fit(X_train, y_train) + +################################################################################ +# +# +# +# Text + + +pred = knn.predict(X_test) +print(pred) + +################################################################################ +# +# +# +# Text + +score = knn.score(X_test, y_test) +print(score) + +################################################################################ +# +# +# +# Text + +probs = knn.predict_proba(X_test[:5]) +print(probs) + + +################################################################################ +# +# +# +# Text + +param_grid = {'n_neighbors': np.arange(1, 12, 2)} + + +knn = KNeighborsClassifier() +gscv = GridSearchCV(knn, param_grid, cv=KFold(shuffle=True, random_state=0)) +gscv.fit(X, y) + + +print("Best params:", gscv.best_params_) +print("Best score:", gscv.best_score_) + + +################################################################################ +# +# +# +# Text + + +plt.figure() +plt.bar(param_grid['n_neighbors'], gscv.cv_results_['mean_test_score']) + +plt.xticks(param_grid['n_neighbors']) +plt.ylabel("Number of Neighbors") +plt.xlabel("Test score") +plt.ylim((0.9, 1)) + + +################################################################################ +# +# +# +# Text + + +knn = KNeighborsClassifier(metric='euclidean', sklearn_metric=True) +gscv2 = GridSearchCV(knn, param_grid, cv=KFold(shuffle=True, random_state=0)) +gscv2.fit(X, y) + +print("Best params:", gscv2.best_params_) + +################################################################################ +# +# +# +# Text + +print("Mean score time (seconds)") +print("L2 distance:", np.mean(gscv.cv_results_['mean_score_time']), "(s)") +print("Sklearn distance:", np.mean(gscv2.cv_results_['mean_score_time']), "(s)") + +################################################################################ +# +# +# +# Text + +plt.show() diff --git a/examples/plot_radius_neighbors_classification.py b/examples/plot_radius_neighbors_classification.py new file mode 100644 index 000000000..ef2d7e313 --- /dev/null +++ b/examples/plot_radius_neighbors_classification.py @@ -0,0 +1,171 @@ +""" +Radius nearest neighbors classification +======================================= + +Shows the usage of the k-nearest neighbors classifier. +""" + +# Author: Pablo Marcos Manchón +# License: MIT + +# sphinx_gallery_thumbnail_number = 1 + + +import skfda +import matplotlib.pyplot as plt +import numpy as np +from sklearn.model_selection import train_test_split, GridSearchCV, KFold +from skfda.ml.classification import RadiusNeighborsClassifier +from skfda.misc.metrics import pairwise_distance, lp_distance + + + +################################################################################ +# +# +# +# Text + + +fd1 = skfda.datasets.make_sinusoidal_process(error_std=.0, phase_std=.35, random_state=0) +fd2 = skfda.datasets.make_sinusoidal_process(phase_mean=1.9, error_std=.0, random_state=1) + +fd1.plot(color='C0') +fd2.plot(color='C1') + + +################################################################################ +# +# +# +# Text + + +X = fd1.concatenate(fd2) +y = 15*[0] + 15*[1] + +X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=0) + + +################################################################################ +# +# +# +# Text + + +plt.figure() + +sample = X_test[0] + + +X_train.plot(color='C0') +sample.plot(color='red', linewidth=3) + +lower = sample - 0.3 +upper = sample + 0.3 + +plt.fill_between(sample.sample_points[0], lower.data_matrix.flatten(), + upper.data_matrix[0].flatten(), alpha=.25, color='C1') + + +################################################################################ +# +# +# +# Text + + + +# Creation of pairwise distance +l_inf = pairwise_distance(lp_distance, p=np.inf) +distances = l_inf(sample, X_train)[0] # L_inf distances to 'sample' + + +plt.figure() +X_train[distances > .3].plot(color='C0') +X_train[distances <= .3].plot(color='C1') +sample.plot(color='red', linewidth=3) + +plt.fill_between(sample.sample_points[0], lower.data_matrix.flatten(), + upper.data_matrix[0].flatten(), alpha=.25, color='C1') + + +################################################################################ +# +# +# +# Text + +radius_nn = RadiusNeighborsClassifier(radius=.3, weights='distance') +radius_nn.fit(X_train, y_train) + + +################################################################################ +# +# +# +# Text + +pred = radius_nn.predict(X_test) +print(pred) + +################################################################################ +# +# +# +# Text + +test_score = radius_nn.score(X_test, y_test) +print(test_score) + +################################################################################ +# +# +# +# Text + +radius_nn = RadiusNeighborsClassifier(radius=3, metric='euclidean', + weights='distance', sklearn_metric=True) + + +radius_nn.fit(X_train, y_train) + +test_score = radius_nn.score(X_test, y_test) +print(test_score) + + +################################################################################ +# +# +# +# Text + +radius_nn.set_params(radius=.5) +radius_nn.fit(X_train, y_train) + +try: + radius_nn.predict(X_test) +except ValueError as e: + print(e) + +################################################################################ +# +# +# +# Text + +radius_nn.set_params(outlier_label=2) +radius_nn.fit(X_train, y_train) +pred = radius_nn.predict(X_test) + +print(pred) + +################################################################################ +# +# +# +# Text + + +plt.show() diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 7089a40e7..21adf5124 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -115,6 +115,10 @@ def vectorial_norm(fdatagrid, p=2): 1 """ + + if p == 'inf': + p == numpy.inf + data_matrix = numpy.linalg.norm(fdatagrid.data_matrix, ord=p, axis=-1, keepdims=True) @@ -248,7 +252,8 @@ def norm_lp(fdatagrid, p=2, p2=2): Args: fdatagrid (FDataGrid): FDataGrid object. p (int, optional): p of the lp norm. Must be greater or equal - than 1. Defaults to 2. + than 1. If p='inf' or p=np.inf it is used the L infinity metric. + Defaults to 2. p2 (int, optional): p index of the vectorial norm applied in case of multivariate objects. Defaults to 2. @@ -286,7 +291,14 @@ def norm_lp(fdatagrid, p=2, p2=2): else: data_matrix = numpy.abs(fdatagrid.data_matrix) - if fdatagrid.ndim_domain == 1: + if p == 'inf' or numpy.isinf(p): + + if fdatagrid.ndim_domain == 1: + res = numpy.max(data_matrix[..., 0], axis=1) + else: + res = np.array([np.max(sample) for sample in data_matrix]) + + elif fdatagrid.ndim_domain == 1: # Computes the norm, approximating the integral with Simpson's rule. res = scipy.integrate.simps(data_matrix[..., 0] ** p, diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index fb19f89f2..6e952d2c0 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -2,6 +2,7 @@ from abc import ABCMeta, abstractmethod, abstractproperty +import numpy as np from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted from sklearn.utils.multiclass import check_classification_targets @@ -20,6 +21,11 @@ from ..misc.metrics import lp_distance, pairwise_distance from ..exploratory.stats import mean +__all__ = ['NearestNeighbors', 'KNeighborsClassifier', + 'RadiusNeighborsClassifier', 'NearestCentroids', + 'KNeighborsScalarRegressor', 'RadiusNeighborsScalarRegressor', + 'KNeighborsFunctionalRegressor', 'RadiusNeighborsFunctionalRegressor' + ] def _to_multivariate(fdatagrid): r"""Returns the data matrix of a fdatagrid in flatten form compatible with @@ -79,6 +85,7 @@ def _to_sklearn_metric(metric, sample_points): >>> from skfda.ml._neighbors import _to_sklearn_metric Calculate the Lp distance between fd and fd2. + >>> x = np.linspace(0, 1, 101) >>> fd = FDataGrid([np.ones(len(x))], x) >>> fd2 = FDataGrid([np.zeros(len(x))], x) @@ -86,6 +93,7 @@ def _to_sklearn_metric(metric, sample_points): 1.0 Creation of the sklearn-style metric. + >>> sklearn_lp_distance = _to_sklearn_metric(lp_distance, [x]) >>> sklearn_lp_distance(np.ones(len(x)), np.zeros(len(x))).round(2) 1.0 @@ -110,7 +118,7 @@ class NeighborsBase(BaseEstimator, metaclass=ABCMeta): def __init__(self, n_neighbors=None, radius=None, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=None): + n_jobs=None, sklearn_metric=False): self.n_neighbors = n_neighbors self.radius = radius @@ -120,43 +128,13 @@ def __init__(self, n_neighbors=None, radius=None, self.metric = metric self.metric_params = metric_params self.n_jobs = n_jobs + self.sklearn_metric = sklearn_metric @abstractmethod def _init_estimator(self, sk_metric): """Initializes the estimator returned by :meth:`_sklearn_neighbors`.""" pass - def fit(self, X, y): - """Fit the model using X as training data and y as target values. - - Args: - X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid - with the training data or array matrix with shape - [n_samples, n_samples] if metric='precomputed'. - y (array-like or sparse matrix): Target values of - shape = [n_samples] or [n_samples, n_outputs]. - - Note: - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - # If metric is precomputed no different with the Sklearn stimator - if self.metric == 'precomputed': - self.estimator_ = self._init_estimator(self.metric) - self.estimator_.fit(X, y) - else: - self._sample_points = X.sample_points - self._shape = X.data_matrix.shape[1:] - - # Constructs sklearn metric to manage vector instead of FDatagrids - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) - - self.estimator_ = self._init_estimator(sk_metric) - self.estimator_.fit(self._transform_to_multivariate(X), y) - - return self - def _check_is_fitted(self): """Check if the estimator is fitted. @@ -168,7 +146,7 @@ def _check_is_fitted(self): def _transform_to_multivariate(self, X): """Transform the input data to array form. If the metric is - precomputed it is not transformet. + precomputed it is not transformed. """ if X is not None and self.metric != 'precomputed': @@ -186,6 +164,42 @@ def _transform_from_multivariate(self, X): return _from_multivariate(X, self._sample_points, shape) +class NeighborsMixin: + """Mixin class to train the neighbors models""" + def fit(self, X, y): + """Fit the model using X as training data and y as target values. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + y (array-like or sparse matrix): Target values of + shape = [n_samples] or [n_samples, n_outputs]. + + Note: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + # If metric is precomputed no diferences with the Sklearn stimator + if self.metric == 'precomputed': + self.estimator_ = self._init_estimator(self.metric) + self.estimator_.fit(X, y) + else: + self._sample_points = X.sample_points + self._shape = X.data_matrix.shape[1:] + + if not self.sklearn_metric: + # Constructs sklearn metric to manage vector + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + else: + sk_metric = self.metric + + self.estimator_ = self._init_estimator(sk_metric) + self.estimator_.fit(self._transform_to_multivariate(X), y) + + return self + class KNeighborsMixin: """Mixin class for K-Neighbors""" @@ -211,6 +225,33 @@ def kneighbors(self, X=None, n_neighbors=None, return_distance=True): ind : array Indices of the nearest points in the population matrix. + Examples: + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors() + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can query the k-nearest neighbors. + + >>> distances, index = neigh.kneighbors(fd[:2]) + >>> index # Index of k-neighbors of samples 0 and 1 + array([[ 0, 7, 6, 11, 2], + [ 1, 14, 13, 9, 7]]) + + >>> distances.round(2) # Distances to k-neighbors + array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], + [ 0. , 0.27, 0.28, 0.29, 0.3 ]]) + Notes: This method wraps the corresponding sklearn routine in the module ``sklearn.neighbors``. @@ -241,8 +282,31 @@ def kneighbors_graph(self, X=None, n_neighbors=None, mode='connectivity'): n_samples_fit is the number of samples in the fitted data A[i, j] is assigned the weight of edge that connects i to j. - See also: - NearestNeighbors.radius_neighbors_graph + Examples: + Firstly, we will create a toy dataset with 2 classes. + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator. + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors() + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can obtain the graph of k-neighbors of a sample. + + >>> graph = neigh.kneighbors_graph(fd[0]) + >>> print(graph) + (0, 0) 1.0 + (0, 7) 1.0 + (0, 6) 1.0 + (0, 11) 1.0 + (0, 2) 1.0 Notes: This method wraps the corresponding sklearn routine in the @@ -287,6 +351,32 @@ def radius_neighbors(self, X=None, radius=None, return_distance=True): approximate nearest points from the population matrix that lie within a ball of size ``radius`` around the query points. + Examples: + Firstly, we will create a toy dataset with 2 classes. + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator. + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors(radius=.3) + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can query the neighbors in the radius. + + >>> distances, index = neigh.radius_neighbors(fd[:2]) + >>> index[0] # Neighbors of sample 0 + array([ 0, 2, 6, 7, 11]) + + >>> distances[0].round(2) # Distances to neighbors of the sample 0 + array([ 0. , 0.3 , 0.29, 0.28, 0.29]) + + See also: kneighbors @@ -329,9 +419,6 @@ def radius_neighbors_graph(self, X=None, radius=None, mode='connectivity'): sparse matrix in CSR format, shape = [n_samples, n_samples] A[i, j] is assigned the weight of edge that connects i to j. - See also: - kneighbors_graph - Notes: This method wraps the corresponding sklearn routine in the module ``sklearn.neighbors``. @@ -397,8 +484,27 @@ def predict(self, X): return self.estimator_.predict(X) +class NearestNeighborsMixinInit: + def _init_estimator(self, sk_metric): + """Initialize the sklearn nearest neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + return _NearestNeighbors( + n_neighbors=self.n_neighbors, radius=self.radius, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) -class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): +class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, + KNeighborsMixin, RadiusNeighborsMixin): """Unsupervised learner for implementing neighbor searches. Parameters @@ -433,6 +539,47 @@ class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. Doesn't affect :meth:`fit` method. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors(radius=.3) + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can query the k-nearest neighbors. + + >>> distances, index = neigh.kneighbors(fd[:2]) + >>> index # Index of k-neighbors of samples 0 and 1 + array([[ 0, 7, 6, 11, 2], + [ 1, 14, 13, 9, 7]]) + + >>> distances.round(2) # Distances to k-neighbors + array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], + [ 0. , 0.27, 0.28, 0.29, 0.3 ]]) + + We can query the neighbors in a given radius too. + + >>> distances, index = neigh.radius_neighbors(fd[:2]) + >>> index[0] # Neighbors of sample 0 + array([ 0, 2, 6, 7, 11]) + + >>> distances[0].round(2) # Distances to neighbors of the sample 0 + array([ 0. , 0.3 , 0.29, 0.28, 0.29]) + See also -------- KNeighborsClassifier @@ -454,39 +601,22 @@ class NearestNeighbors(NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin): def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1): + n_jobs=1, sklearn_metric=False): """Initialize the nearest neighbors searcher.""" super().__init__(n_neighbors=n_neighbors, radius=radius, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, - n_jobs=n_jobs) - - def _init_estimator(self, sk_metric): - """Initialize the sklearn nearest neighbors estimator. - - Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn K Neighbors estimator initialized. + n_jobs=n_jobs, sklearn_metric=sklearn_metric) - """ - return _NearestNeighbors( - n_neighbors=self.n_neighbors, radius=self.radius, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) - - def fit(self, X): + def fit(self, X, y=None): """Fit the model using X as training data. Args: X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid with the training data or array matrix with shape [n_samples, n_samples] if metric='precomputed'. + y (None) : Parameter ignored. Note: This method wraps the corresponding sklearn routine in the module @@ -510,8 +640,8 @@ def fit(self, X): return self -class KNeighborsClassifier(NeighborsBase, KNeighborsMixin, ClassifierMixin, - NeighborsClassifierMixin): +class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, + ClassifierMixin, NeighborsClassifierMixin): """Classifier implementing the k-nearest neighbors vote. Parameters @@ -555,6 +685,38 @@ class KNeighborsClassifier(NeighborsBase, KNeighborsMixin, ClassifierMixin, ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. Doesn't affect :meth:`fit` method. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + >>> y = 15*[0] + 15*[1] + + We will fit a K-Nearest Neighbors classifier + + >>> from skfda.ml.classification import KNeighborsClassifier + >>> neigh = KNeighborsClassifier() + >>> neigh.fit(fd, y) + KNeighborsClassifier(algorithm='auto', leaf_size=30,...) + + We can predict the class of new samples + + >>> neigh.predict(fd[::2]) # Predict labels for even samples + array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + + And the estimated probabilities. + + >>> neigh.predict_proba(fd[0]) # Probabilities of sample 0 + array([[ 1., 0.]]) + See also -------- RadiusNeighborsClassifier @@ -582,13 +744,14 @@ class KNeighborsClassifier(NeighborsBase, KNeighborsMixin, ClassifierMixin, def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1): + n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" super().__init__(n_neighbors=n_neighbors, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs) + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) def _init_estimator(self, sk_metric): """Initialize the sklearn K neighbors estimator. @@ -629,8 +792,9 @@ def predict_proba(self, X): return self.estimator_.predict_proba(X) -class RadiusNeighborsClassifier(NeighborsBase, RadiusNeighborsMixin, - ClassifierMixin, NeighborsClassifierMixin): +class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, + RadiusNeighborsMixin, ClassifierMixin, + NeighborsClassifierMixin): """Classifier implementing a vote among neighbors within a given radius Parameters @@ -679,6 +843,32 @@ class RadiusNeighborsClassifier(NeighborsBase, RadiusNeighborsMixin, The number of parallel jobs to run for neighbors search. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with 2 classes. + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + >>> y = 15*[0] + 15*[1] + + We will fit a Radius Nearest Neighbors classifier. + + >>> from skfda.ml.classification import RadiusNeighborsClassifier + >>> neigh = RadiusNeighborsClassifier(radius=.3) + >>> neigh.fit(fd, y) + RadiusNeighborsClassifier(algorithm='auto', leaf_size=30,...) + + We can predict the class of new samples. + + >>> neigh.predict(fd[::2]) # Predict labels for even samples + array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) See also -------- @@ -702,12 +892,13 @@ class RadiusNeighborsClassifier(NeighborsBase, RadiusNeighborsMixin, def __init__(self, radius=1.0, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, - outlier_label=None, n_jobs=1): + outlier_label=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" super().__init__(radius=radius, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs) + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) self.outlier_label = outlier_label @@ -756,6 +947,28 @@ class NearestCentroids(BaseEstimator, ClassifierMixin): ---------- centroids_ : :class:`FDataGrid` FDatagrid containing the centroid of each class + Examples + -------- + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + >>> y = 15*[0] + 15*[1] + + We will fit a Nearest centroids classifier + + >>> from skfda.ml.classification import NearestCentroids + >>> neigh = NearestCentroids() + >>> neigh.fit(fd, y) + NearestCentroids(...) + + We can predict the class of new samples + + >>> neigh.predict(fd[::2]) # Predict labels for even samples + array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) See also -------- KNeighborsClassifier @@ -805,6 +1018,8 @@ def fit(self, X, y): centroid = self.mean(X[center_mask]) self.centroids_ = self.centroids_.concatenate(centroid) + return self + def predict(self, X): """Predict the class labels for the provided data. @@ -822,7 +1037,8 @@ def predict(self, X): return self.classes_[self._pairwise_distance( X, self.centroids_).argmin(axis=1)] -class KNeighborsScalarRegressor(NeighborsBase, KNeighborsMixin, RegressorMixin, +class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, + KNeighborsMixin, RegressorMixin, NeighborsScalarRegresorMixin): """Regression based on k-nearest neighbors with scalar response. @@ -870,6 +1086,32 @@ class KNeighborsScalarRegressor(NeighborsBase, KNeighborsMixin, RegressorMixin, ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. Doesn't affect :meth:`fit` method. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted. + + >>> from skfda.datasets import make_multimodal_samples + >>> from skfda.datasets import make_multimodal_landmarks + >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) + >>> y = y.flatten() + >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + + We will fit a K-Nearest Neighbors regressor to regress a scalar response. + + >>> from skfda.ml.regression import KNeighborsScalarRegressor + >>> neigh = KNeighborsScalarRegressor() + >>> neigh.fit(fd, y) + KNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the modes of new samples + + >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations + array([ 0.79, 0.27, 0.71, 0.79]) + See also -------- KNeighborsClassifier @@ -896,13 +1138,14 @@ class KNeighborsScalarRegressor(NeighborsBase, KNeighborsMixin, RegressorMixin, """ def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1): + n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" super().__init__(n_neighbors=n_neighbors, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs) + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) def _init_estimator(self, sk_metric): """Initialize the sklearn K neighbors estimator. @@ -922,8 +1165,8 @@ def _init_estimator(self, sk_metric): metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) -class RadiusNeighborsScalarRegressor(NeighborsBase, RadiusNeighborsMixin, - RegressorMixin, +class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, + RadiusNeighborsMixin, RegressorMixin, NeighborsScalarRegresorMixin): """Scalar regression based on neighbors within a fixed radius. @@ -972,6 +1215,32 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, RadiusNeighborsMixin, The number of parallel jobs to run for neighbors search. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted. + + >>> from skfda.datasets import make_multimodal_samples + >>> from skfda.datasets import make_multimodal_landmarks + >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) + >>> y = y.flatten() + >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + + + We will fit a K-Nearest Neighbors regressor to regress a scalar response. + + >>> from skfda.ml.regression import RadiusNeighborsScalarRegressor + >>> neigh = RadiusNeighborsScalarRegressor(radius=.2) + >>> neigh.fit(fd, y) + RadiusNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the modes of new samples. + + >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations + array([ 0.84, 0.27, 0.66, 0.79]) See also -------- @@ -994,12 +1263,13 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, RadiusNeighborsMixin, def __init__(self, radius=1.0, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1): + n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" super().__init__(radius=radius, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs) + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) def _init_estimator(self, sk_metric): @@ -1019,3 +1289,375 @@ def _init_estimator(self, sk_metric): algorithm=self.algorithm, leaf_size=self.leaf_size, metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) + +class NeighborsFunctionalRegressorMixin: + """Mixin class for the functional regressors based in neighbors""" + + def fit(self, X, y): + """Fit the model using X as training data. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + + + """ + if (X.nsamples != y.nsamples): + raise ValueError("The response and dependent variable must " + "contain the same number of samples,") + + # If metric is precomputed no different with the Sklearn stimator + if self.metric == 'precomputed': + self.estimator_ = self._init_estimator(self.metric) + self.estimator_.fit(X) + else: + self._sample_points = X.sample_points + self._shape = X.data_matrix.shape[1:] + + # Constructs sklearn metric to manage vector instead of FDatagrids + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + + self.estimator_ = self._init_estimator(sk_metric) + self.estimator_.fit(self._transform_to_multivariate(X)) + + # Choose proper local regressor + if self.weights == 'uniform': + self.local_regressor = self._uniform_local_regression + elif self.weight == 'distance': + self.local_regressor = self._distance_local_regression + else: + self.local_regressor = self._weighted_local_regression + + # Store the responses + self._y = y + + return self + + def _uniform_local_regression(self, neighbors, distance=None): + """Perform local regression with uniform weights""" + return self.regressor(neighbors) + + def _distance_local_regression(self, neighbors, distance): + """Perform local regression using distances as weights""" + idx = distance == 0. + if np.any(idx): + weights = distance + weights[idx] = 1. / np.sum(idx) + weights[~idx] = 0. + else: + weights = 1. / distance + weights /= np.sum(weights) + + return self.regressor(neighbors, weights) + + + def _weighted_local_regression(self, neighbors, distance): + """Perform local regression using custom weights""" + + weights = self.weights(distance) + + return self.regressor(neighbors, weights) + + def predict(self, X): + """Predict functional responses. + + Args: + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + + Returns + + y : :class:`FDataGrid` containing as many samples as X. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + distances, neighbors = self._query(X) + + + # Todo: change the concatenation after merge image-operations branch + if len(neighbors[0]) == 0: + pred = self._outlier_response(neighbors) + else: + pred = self.local_regressor(self._y[neighbors[0]], distances[0]) + + for i, idx in enumerate(neighbors[1:]): + if len(idx) == 0: + new_pred = self._outlier_response(neighbors) + else: + new_pred = self.local_regressor(self._y[idx], distances[i+1]) + + pred = pred.concatenate(new_pred) + + return pred + + def _outlier_response(self, neighbors): + """Response in case of no neighbors""" + + if (not hasattr(self, "outlier_response") or + self.outlier_response is None): + index = np.where([len(n)==0 for n in neighbors])[0] + + raise ValueError(f"No neighbors found for test samples {index}, " + "you can try using larger radius, give a reponse " + "for outliers, or consider removing them from your" + " dataset.") + else: + return self.outlier_response + + + @abstractmethod + def _query(self): + """Return distances and neighbors of given sample""" + pass + + def score(self, X, y): + """TODO""" + + # something like + # pred = self.pred(X) + # return score(pred, y) + # + raise NotImplementedError + +class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, + NeighborsBase, KNeighborsMixin, + NeighborsFunctionalRegressorMixin): + """Functional regression based on neighbors within a fixed radius. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + regressor : callable, optional ((default = + :func:`mean `)) + Function to perform the local regression. By default used the mean. Can + accept a user-defined function wich accepts a :class:`FDataGrid` with + the neighbors of a test sample, and if weights != 'uniform' an array + of weights as second parameter. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted, + and we will try to predict 5 X +1. + + >>> from skfda.datasets import make_multimodal_samples + >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> y_train = 5 * X_train + 1 + >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) + + We will fit a K-Nearest Neighbors functional regressor. + + >>> from skfda.ml.regression import KNeighborsFunctionalRegressor + >>> neigh = KNeighborsFunctionalRegressor() + >>> neigh.fit(X_train, y_train) + KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the response of new samples. + + >>> neigh.predict(X_test) + FDataGrid(...) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + + def __init__(self, n_neighbors=5, weights='uniform', regressor=mean, + algorithm='auto', leaf_size=30, metric=lp_distance, + metric_params=None, n_jobs=1, sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(n_neighbors=n_neighbors, radius=1., + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + self.regressor = regressor + + def _query(self, X): + """Return distances and neighbors of given sample""" + return self.estimator_.kneighbors(X) + +class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, + NeighborsBase, RadiusNeighborsMixin, + NeighborsFunctionalRegressorMixin): + """Functional regression based on neighbors within a fixed radius. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + regressor : callable, optional ((default = + :func:`mean `)) + Function to perform the local regression. By default used the mean. Can + accept a user-defined function wich accepts a :class:`FDataGrid` with + the neighbors of a test sample, and if weights != 'uniform' an array + of weights as second parameter. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + outlier_response : :class:`FDataGrid`, optional (default = None) + Default response for test samples without neighbors. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted, + and we will try to predict 5 X +1. + + >>> from skfda.datasets import make_multimodal_samples + >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> y_train = 5 * X_train + 1 + >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) + + We will fit a Radius Nearest Neighbors functional regressor. + + >>> from skfda.ml.regression import RadiusNeighborsFunctionalRegressor + >>> neigh = RadiusNeighborsFunctionalRegressor(radius=.03) + >>> neigh.fit(X_train, y_train) + KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the response of new samples. + + >>> neigh.predict(X_test) + FDataGrid(...) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, radius=1., weights='uniform', regressor=mean, + algorithm='auto', leaf_size=30, metric=lp_distance, + metric_params=None, outlier_response=None, n_jobs=1, + sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(n_neighbors=5, radius=radius, + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + self.regressor = regressor + self.outlier_response = outlier_response + + def _query(self, X): + """Return distances and neighbors of given sample""" + return self.estimator_.radius_neighbors(X) diff --git a/skfda/ml/regression/__init__.py b/skfda/ml/regression/__init__.py index 6cb7fd409..c4b2cbb5f 100644 --- a/skfda/ml/regression/__init__.py +++ b/skfda/ml/regression/__init__.py @@ -1,3 +1,5 @@ from .._neighbors import (KNeighborsScalarRegressor, - RadiusNeighborsScalarRegressor) + RadiusNeighborsScalarRegressor, + KNeighborsFunctionalRegressor, + RadiusNeighborsFunctionalRegressor) From be5900d14ae05637329e0b63e57853cc32296451 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sun, 26 May 2019 18:43:30 +0200 Subject: [PATCH 040/222] Doctest fixed --- skfda/ml/_neighbors.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index 6e952d2c0..87eeba706 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -969,6 +969,7 @@ class NearestCentroids(BaseEstimator, ClassifierMixin): >>> neigh.predict(fd[::2]) # Predict labels for even samples array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + See also -------- KNeighborsClassifier @@ -1618,7 +1619,7 @@ class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, >>> from skfda.ml.regression import RadiusNeighborsFunctionalRegressor >>> neigh = RadiusNeighborsFunctionalRegressor(radius=.03) >>> neigh.fit(X_train, y_train) - KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + RadiusNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) We can predict the response of new samples. From 17627a3c83a1503a84ec970966dec933e3fe01c0 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sun, 26 May 2019 22:48:30 +0200 Subject: [PATCH 041/222] Examples of classification --- examples/plot_k_neighbors_classification.py | 100 ++++++++++++---- .../plot_radius_neighbors_classification.py | 108 ++++++++++++------ skfda/exploratory/stats/_stats.py | 11 +- skfda/representation/_functional_data.py | 4 +- skfda/representation/basis.py | 10 +- skfda/representation/grid.py | 9 +- 6 files changed, 174 insertions(+), 68 deletions(-) diff --git a/examples/plot_k_neighbors_classification.py b/examples/plot_k_neighbors_classification.py index 6c1cec08f..dc6b4bc1a 100644 --- a/examples/plot_k_neighbors_classification.py +++ b/examples/plot_k_neighbors_classification.py @@ -17,8 +17,17 @@ ################################################################################ # -# Text +# In this example we are going to show the usage of the K-nearest neighbors +# classifier in their functional version, which is a extension of the +# multivariate one, but using functional metrics between the observations. # +# Firstly, we are going to fetch a functional data dataset, such as the Berkeley +# Growth Study. This dataset correspond to the height of several boys and girls +# measured until the 18 years of age. +# +# We will try to predict the sex by using its growth curves. +# +# The following figure shows the growth curves grouped by sex. # data = skfda.datasets.fetch_growth() @@ -30,54 +39,72 @@ ################################################################################ # +# In this case, the class labels are stored in an array with 0's in the male +# samples and 1's in the positions with female ones. # -# -# Text print(y) ################################################################################ # +# We can split the dataset using the sklearn function +# :func:`train_test_split `. # +# We will use two thirds of the dataset for the training partition and the +# remaining samples for testing. +# +# The function will return two :class:`FDataGrid `'s, +# ``X_train`` and ``X_test`` with the corresponding partitions, and arrays +# with their class labels. # -# Text X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=0) ################################################################################ # +# We will fit the classifier +# :class:`KNeighborsClassifier ` +# with the training partition. This classifier works exactly like the sklearn +# multivariate classifier +# :class:`KNeighborsClassifier ` , but +# will accept as input a :class:`FDataGrid` with functional observations instead +# of an array with multivariate data. # -# -# Text knn = KNeighborsClassifier() knn.fit(X_train, y_train) ################################################################################ # +# Once it is fitted, we can predict labels for the test samples. # +# To predict the label of a test sample, the classifier will calculate the +# k-nearest neighbors and will asign the majority class. By default, it is +# used the :math:`\mathbb{L}^2` distance between functions, to determine the +# neighbourhood of a sample, with 5 neighbors. +# +# Can be used any of the functional metrics of the module +# :mod:`skfda.misc.metrics`. # -# Text - pred = knn.predict(X_test) print(pred) ################################################################################ # +# The :func:`score` method allows us to calculate the mean accuracy for the test +# data. In this case we obtained around 96% of accuracy. # -# -# Text score = knn.score(X_test, y_test) print(score) ################################################################################ # -# -# -# Text +# We can also estimate the probability of membership to the predicted class +# using :func:`predict_proba`, which will return an array with the +# probabilities of the classes, in lexicographic order, for each test sample. probs = knn.predict_proba(X_test[:5]) print(probs) @@ -85,10 +112,14 @@ ################################################################################ # +# We can use the sklearn +# :func:`GridSearchCV ` to perform a +# grid search to select the best hyperparams, using cross-validation. # +# In this case, we will vary the number of neighbors between 1 and 11. # -# Text +# only odd numbers param_grid = {'n_neighbors': np.arange(1, 12, 2)} @@ -103,10 +134,9 @@ ################################################################################ # +# We have obtained the greatest mean accuracy using 3 neighbors. The following +# figure shows the score depending on the number of neighbors. # -# -# Text - plt.figure() plt.bar(param_grid['n_neighbors'], gscv.cv_results_['mean_test_score']) @@ -119,22 +149,48 @@ ################################################################################ # +# In this dataset, the functional observations have been sampled equiespaciated. +# If we approximate the integral of the :math:`\mathbb{L}^2` distance as a +# Riemann sum (actually the Simpson's rule it is used), we obtain that +# it is approximately equivalent to the euclidean distance between vectors. # +# .. math:: +# \|f - g \|_{\mathbb{L}^2} = \left ( \int_a^b |f(x) - g(x)|^2 dx \right ) +# ^{\frac{1}{2}} \approx \left ( \sum_{n=0}^{N}\bigtriangleup h \,|f(x_n) +# - g(x_n)|^2 \right ) ^ {\frac{1}{2}}\\ +# = \sqrt{\bigtriangleup h} \, d_{euclidean}(\vec{f}, \vec{g}) +# +# +# So, in this case, it is roughtly equivalent to use this metric instead of the +# functional one, due to the constant multiplication do no affect the +# order of the neighbors. +# +# Setting the parameter ``sklearn_metric`` of the classifier to True, +# a vectorial metric of sklearn can be passed. In +# :class:`sklearn.neighbors.DistanceMetric` there are listed all the metrics +# supported. +# +# We will fit the model with the sklearn distance and search for the best +# parameter. The results can vary sightly, due to the approximation during +# the integration, but the result should be similar. # -# Text - knn = KNeighborsClassifier(metric='euclidean', sklearn_metric=True) gscv2 = GridSearchCV(knn, param_grid, cv=KFold(shuffle=True, random_state=0)) gscv2.fit(X, y) print("Best params:", gscv2.best_params_) +print("Best score:", gscv2.best_score_) ################################################################################ # +# The advantage of use the sklearn metrics is the computational speed, three +# orders of magnitude faster. But it is not always possible to resample samples +# equiespaced nor do all functional metrics have a vector equivalent in this +# way. # +# The mean score time depending on the metric is shown below. # -# Text print("Mean score time (seconds)") print("L2 distance:", np.mean(gscv.cv_results_['mean_score_time']), "(s)") @@ -142,8 +198,8 @@ ################################################################################ # +# This classifier can be used with multivariate funcional data, as surfaces +# or curves in :math:`\mathbb{R}^N`, if the metric support it too. # -# -# Text plt.show() diff --git a/examples/plot_radius_neighbors_classification.py b/examples/plot_radius_neighbors_classification.py index ef2d7e313..6f35f6be7 100644 --- a/examples/plot_radius_neighbors_classification.py +++ b/examples/plot_radius_neighbors_classification.py @@ -1,14 +1,14 @@ """ -Radius nearest neighbors classification -======================================= +Radius neighbors classification +=============================== -Shows the usage of the k-nearest neighbors classifier. +Shows the usage of the radius nearest neighbors classifier. """ # Author: Pablo Marcos Manchón # License: MIT -# sphinx_gallery_thumbnail_number = 1 +# sphinx_gallery_thumbnail_number = 2 import skfda @@ -19,16 +19,23 @@ from skfda.misc.metrics import pairwise_distance, lp_distance - ################################################################################ # +# In this example, we are going to show the usage of the radius nearest +# neighbors classifier in their functional version, a variation of the K-nearest +# neighbors classifier, where it is used a vote among neighbors within a given +# radius, instead of use the k nearest neighbors. # +# Firstly, we will construct a toy dataset to show the basic usage of the API. +# +# We will create two classes of sinusoidal samples, with different locations +# of their phase. # -# Text - -fd1 = skfda.datasets.make_sinusoidal_process(error_std=.0, phase_std=.35, random_state=0) -fd2 = skfda.datasets.make_sinusoidal_process(phase_mean=1.9, error_std=.0, random_state=1) +fd1 = skfda.datasets.make_sinusoidal_process(error_std=.0, phase_std=.35, + random_state=0) +fd2 = skfda.datasets.make_sinusoidal_process(phase_mean=1.9, error_std=.0, + random_state=1) fd1.plot(color='C0') fd2.plot(color='C1') @@ -36,30 +43,38 @@ ################################################################################ # -# -# -# Text - +# As in the K-nearest neighbor example, we will split the dataset in two +# partitions, for training and test, using the sklearn function +# :func:`sklearn.model_selection.train_test_split`. +# Concatenate the two classes in the same FDataGrid X = fd1.concatenate(fd2) -y = 15*[0] + 15*[1] +y = np.array(15*[0] + 15*[1]) -X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=0) +X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, + shuffle=True, random_state=0) ################################################################################ # +# As in the multivariate data, the label assigned to a test sample will be the +# majority class of its neighbors, in this case all the samples in the ball +# center in the sample. # +# If we use the :math:`\mathbb{L}^\infty` metric, we can visualize a ball +# as a bandwidth with a fixed radius around a function. +# +# The following figure shows the ball centered in the first sample of the test +# partition. # -# Text plt.figure() sample = X_test[0] - -X_train.plot(color='C0') +X_train[y_train == 0].plot(color='C0') +X_train[y_train == 1].plot(color='C1') sample.plot(color='red', linewidth=3) lower = sample - 0.3 @@ -71,20 +86,18 @@ ################################################################################ # +# In this case, all the neighbors in the ball belong to the first class, so +# this will be the class predicted. # -# -# Text - # Creation of pairwise distance l_inf = pairwise_distance(lp_distance, p=np.inf) distances = l_inf(sample, X_train)[0] # L_inf distances to 'sample' - plt.figure() -X_train[distances > .3].plot(color='C0') -X_train[distances <= .3].plot(color='C1') + +X_train[distances <= .3].plot(color='C0') sample.plot(color='red', linewidth=3) plt.fill_between(sample.sample_points[0], lower.data_matrix.flatten(), @@ -93,9 +106,14 @@ ################################################################################ # +# We will fit the classifier :class:`RadiusNeighborsClassifier +# `, which has a similar API +# than the sklearn estimator :class:`sklearn.neighbors.RadiusNeighborsClassifier` +# but accepting :class:`FDataGrid` instead of arrays with multivariate data. # +# The vote of the neighbors can be weighted using the paramenter ``weights``. +# In this case we will weight the vote inversely proportional to the distance. # -# Text radius_nn = RadiusNeighborsClassifier(radius=.3, weights='distance') radius_nn.fit(X_train, y_train) @@ -103,27 +121,43 @@ ################################################################################ # +# We can predict labels for the test partition with :meth:`predict`. # -# -# Text pred = radius_nn.predict(X_test) print(pred) ################################################################################ # +# In this case, we get 100% accuracy, althouth, it is a toy dataset and it does +# not have much merit. # -# -# Text test_score = radius_nn.score(X_test, y_test) print(test_score) ################################################################################ # +# As in the K-nearest neighbor example, we can use a sklearn metric +# approximately equivalent to the functional :math:`\mathbb{L}^2` one, +# but computationally faster. +# +# We saw that :math:`\|f -g \|_{\mathbb{L}^2} \approx \sqrt{\bigtriangleup h} \, +# d_{euclidean}(\vec{f}, \vec{g})` if the samples are equiespaced (or almost). +# +# In the KNN case, the constant :math:`\sqrt{\bigtriangleup h}` does not matter, +# but in this case will affect the value of the radius, dividing by +# :math:`\sqrt{\bigtriangleup h}`. +# +# In this dataset :math:`\bigtriangleup h=0.001`, so, we have to multiply the +# radius by :math:`10` to achieve the same result. # +# The computation using this metric it is 1000 times faster. See the +# K-neighbors classifier example and the API documentation to get detailled +# information. +# +# We obtain 100% accuracy with this metric too. # -# Text radius_nn = RadiusNeighborsClassifier(radius=3, metric='euclidean', weights='distance', sklearn_metric=True) @@ -137,11 +171,11 @@ ################################################################################ # +# If the radius is too small, it is possible to get samples with no neighbors. +# The classifier will raise and exception in this case. # -# -# Text -radius_nn.set_params(radius=.5) +radius_nn.set_params(radius=.5) # Radius 0.05 in the L2 distance radius_nn.fit(X_train, y_train) try: @@ -151,9 +185,8 @@ ################################################################################ # +# A label to these oulier samples can be provided to avoid this problem. # -# -# Text radius_nn.set_params(outlier_label=2) radius_nn.fit(X_train, y_train) @@ -163,9 +196,8 @@ ################################################################################ # +# This classifier can be used with multivariate funcional data, as surfaces +# or curves in :math:`\mathbb{R}^N`, if the metric support it too. # -# -# Text - plt.show() diff --git a/skfda/exploratory/stats/_stats.py b/skfda/exploratory/stats/_stats.py index 77de0de17..55dfd7c2c 100644 --- a/skfda/exploratory/stats/_stats.py +++ b/skfda/exploratory/stats/_stats.py @@ -2,15 +2,16 @@ """ -def mean(fdata): +def mean(fdata, weights=None): """Compute the mean of all the samples in a FData object. Computes the mean of all the samples in a FDataGrid or FDataBasis object. Args: - fdata(FDataGrid or FDataBasis): Object containing all the samples - whose mean - is wanted. + fdata (FDataGrid or FDataBasis): Object containing all the samples + whose mean is wanted. + weight (array-like, optional): List of weights. + Returns: FDataGrid or FDataBasis: A FDataGrid or FDataBasis object with just @@ -18,7 +19,7 @@ def mean(fdata): object. """ - return fdata.mean() + return fdata.mean(weights) def var(fdatagrid): diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 5996d4766..47c05417b 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -946,9 +946,11 @@ def copy(self, **kwargs): pass @abstractmethod - def mean(self): + def mean(self, weights=None): """Compute the mean of all the samples. + weights (array-like, optional): List of weights. + Returns: FData : A FData object with just one sample representing the mean of all the samples in the original object. diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index d863d28b5..e7ca6ec0d 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1878,7 +1878,7 @@ def derivative(self, order=1): return FDataBasis(basis, coefficients) - def mean(self): + def mean(self, weights=None): """Compute the mean of all the samples in a FDataBasis object. Returns: @@ -1896,6 +1896,14 @@ def mean(self): ...) """ + if weights is not None: + return self.copy(coefficients= + numpy.average(self.coefficients, + weights=weights, + axis=0 + )[numpy.newaxis,...] + ) + return self.copy(coefficients=numpy.mean(self.coefficients, axis=0)) def gmean(self, eval_points=None): diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 9637ce52c..df6b4e81a 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -449,14 +449,21 @@ def __check_same_dimensions(self, other): if not numpy.array_equal(self.sample_points, other.sample_points): raise ValueError("Sample points for both objects must be equal") - def mean(self): + def mean(self, weights=None): """Compute the mean of all the samples. + weights (array-like, optional): List of weights. Returns: FDataGrid : A FDataGrid object with just one sample representing the mean of all the samples in the original object. """ + if weights is not None: + + return self.copy(data_matrix=numpy.average( + self.data_matrix, weights=weights, axis=0)[numpy.newaxis,...] + ) + return self.copy(data_matrix=self.data_matrix.mean(axis=0, keepdims=True)) From 3114235bad64d73d3de9423bfaa5ae755a836d0e Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 27 May 2019 11:42:29 +0200 Subject: [PATCH 042/222] Scalar regression example --- examples/plot_neighbors_scalar_regression.py | 189 +++++++++++++++++++ skfda/ml/_neighbors.py | 7 +- 2 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 examples/plot_neighbors_scalar_regression.py diff --git a/examples/plot_neighbors_scalar_regression.py b/examples/plot_neighbors_scalar_regression.py new file mode 100644 index 000000000..447856be3 --- /dev/null +++ b/examples/plot_neighbors_scalar_regression.py @@ -0,0 +1,189 @@ +""" +Neighbors Scalar Regression +=========================== + +Shows the usage of the nearest neighbors regressor with scalar response. +""" + +# Author: Pablo Marcos Manchón +# License: MIT + +# sphinx_gallery_thumbnail_number = 3 + +import skfda +import matplotlib.pyplot as plt +import numpy as np +from sklearn.model_selection import train_test_split, GridSearchCV, KFold +from skfda.ml.regression import KNeighborsScalarRegressor +from skfda.misc.metrics import norm_lp + + +################################################################################ +# +# In this example, we are going to show the usage of the nearest neighbors +# regressors with scalar response. There is available a K-nn version, +# :class:`KNeighborsScalarRegressor +# `, and other one based in the +# radius, :class:`RadiusNeighborsScalarRegressor +# `. +# +# Firstly we will fetch a dataset to show the basic usage. +# +# The caniadian weather dataset contains the daily temperature and precipitation +# at 35 different locations in Canada averaged over 1960 to 1994. +# +# The following figure shows the different temperature curves. +# + +data = skfda.datasets.fetch_weather() +fd = data['data'] + +# TODO: Change this after merge operations-with-images +fd.axes_labels = None +X = fd.copy(data_matrix=fd.data_matrix[..., 0]) + + +X.plot() + + +################################################################################ +# +# In this example we are not interested in the precipitation curves directly, +# as in the case with regression response, we will train a nearest neighbor +# regressor to predict a scalar magnitude. +# +# In the next figure the precipitation curves are shown. +# + +y_func = fd.copy(data_matrix=fd.data_matrix[..., 1]) + +plt.figure() +y_func.plot() + +################################################################################ +# +# We will try to predict the total log precipitation, i.e, +# :math:`logPrecTot_i = \log \int_0^{365} prec_i(t)dt` using the temperature +# curves. +# +# To obtain the precTot we will calculate the :math:`\mathbb{L}^1` norm of +# the precipitation curves. +# + +prec = norm_lp(y_func, 1) +log_prec = np.log(prec) + +print(log_prec) + +################################################################################ +# +# As in the nearest neighbors classifier examples, we will split the dataset in +# two partitions, for training and test, using the sklearn function +# :func:`sklearn.model_selection.train_test_split`. +# + +X_train, X_test, y_train, y_test = train_test_split(X, log_prec, random_state=7) + +################################################################################ +# +# Firstly we will try make a prediction with the default values of the +# estimator, using 5 neighbors and the :math:`\mathbb{L}^2`. +# +# We can fit the :class:`KNeighborsScalarRegressor +# ` in the same way than the +# sklearn estimators. This estimator is an extension of the sklearn +# :class:`sklearn.neighbors.KNeighborsRegressor`, but accepting a +# :class:`FDataGrid ` as input instead of an array with +# multivariate data. +# + + +knn = KNeighborsScalarRegressor(weights='distance') +knn.fit(X_train, y_train) + +################################################################################ +# +# We can predict values for the test partition using :meth:`predict`. +# + +pred = knn.predict(X_test) +print(pred) + +################################################################################ +# +# The following figure compares the real precipitations with the predicted +# values. +# + + +plt.figure() +plt.scatter(y_test, pred) +plt.plot(y_test, y_test) +plt.xlabel("Total log precipitation") +plt.ylabel("Prediction") + + +################################################################################ +# +# We can quantify how much variability it is explained by the model with +# the coefficient of determination :math:`R^2` of the prediction, +# using :meth:`score` for that. +# +# The coefficient :math:`R^2` is defined as :math:`(1 - u/v)`, where :math:`u` +# is the residual sum of squares :math:`\sum_i (y_i - y_{pred_i})^ 2` +# and :math:`v` is the total sum of squares :math:`\sum_i (y_i - \bar y )^2`. +# +# + +score = knn.score(X_test, y_test) +print(score) + + +################################################################################ +# +# In this case, we obtain a really good aproximation with this naive approach, +# although, due to the small number of samples, the results will depend on +# how the partition was done. In the above case, the explained variation is +# inflated for this reason. +# +# We will perform cross-validation to test more robustly our model. +# +# As in the neighbors classifiers examples, we can use a sklearn metric to +# approximate the :math:`\mathbb{L}^2` metric between function, but with a much +# lower computational cost. +# +# Also, we can make a grid search, using +# :class:`sklearn.model_selection.GridSearchCV`, to determine the optimal number +# of neighbors and the best way to weight their votes. +# + +param_grid = {'n_neighbors': np.arange(1, 12, 2), + 'weights': ['uniform', 'distance']} + + +knn = KNeighborsScalarRegressor(metric='euclidean', sklearn_metric=True) +gscv = GridSearchCV(knn, param_grid, cv=KFold(shuffle=True, random_state=0)) +gscv.fit(X, log_prec) + +################################################################################ +# +# We obtain that 7 is the optimal number of neighbors, and a lower value of the +# :math:`R^2` coefficient, but much closer to the real one. +# + +print(gscv.best_params_) +print(gscv.best_score_) + +################################################################################ +# +# More detailed information about the canadian weather dataset can be obtained +# in the following references. +# +# * Ramsay, James O., and Silverman, Bernard W. (2006). Functional Data +# Analysis, 2nd ed. , Springer, New York. +# +# * Ramsay, James O., and Silverman, Bernard W. (2002). Applied Functional +# Data Analysis, Springer, New York\n' +# + +plt.show() diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index 87eeba706..3b82ef93c 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -1316,8 +1316,11 @@ def fit(self, X, y): self._sample_points = X.sample_points self._shape = X.data_matrix.shape[1:] - # Constructs sklearn metric to manage vector instead of FDatagrids - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + if not self.sklearn_metric: + # Constructs sklearn metric to manage vector instead of grids + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + else: + sk_metric = self.metric self.estimator_ = self._init_estimator(sk_metric) self.estimator_.fit(self._transform_to_multivariate(X)) From dcb2f431431059f6b733d18aa39b6b8e3a1b2069 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 27 May 2019 11:55:45 +0200 Subject: [PATCH 043/222] Windows doctest failure --- skfda/ml/_neighbors.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index 3b82ef93c..5f2c217b3 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -245,8 +245,7 @@ def kneighbors(self, X=None, n_neighbors=None, return_distance=True): >>> distances, index = neigh.kneighbors(fd[:2]) >>> index # Index of k-neighbors of samples 0 and 1 - array([[ 0, 7, 6, 11, 2], - [ 1, 14, 13, 9, 7]]) + array([[ 0, 7, 6, 11, 2],...) >>> distances.round(2) # Distances to k-neighbors array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], @@ -564,8 +563,7 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, >>> distances, index = neigh.kneighbors(fd[:2]) >>> index # Index of k-neighbors of samples 0 and 1 - array([[ 0, 7, 6, 11, 2], - [ 1, 14, 13, 9, 7]]) + array([[ 0, 7, 6, 11, 2],...) >>> distances.round(2) # Distances to k-neighbors array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], From d7f972b36307265ce4c12c1c34b156d08a9fe254 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 27 May 2019 12:07:53 +0200 Subject: [PATCH 044/222] Windows doctest... --- skfda/ml/_neighbors.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index 5f2c217b3..0c24096cc 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -369,8 +369,8 @@ def radius_neighbors(self, X=None, radius=None, return_distance=True): Now we can query the neighbors in the radius. >>> distances, index = neigh.radius_neighbors(fd[:2]) - >>> index[0] # Neighbors of sample 0 - array([ 0, 2, 6, 7, 11]) + >>> index + array([[ 0, 2, 6, 7, 11],...) >>> distances[0].round(2) # Distances to neighbors of the sample 0 array([ 0. , 0.3 , 0.29, 0.28, 0.29]) @@ -572,8 +572,8 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, We can query the neighbors in a given radius too. >>> distances, index = neigh.radius_neighbors(fd[:2]) - >>> index[0] # Neighbors of sample 0 - array([ 0, 2, 6, 7, 11]) + >>> index + array([[ 0, 2, 6, 7, 11],...) >>> distances[0].round(2) # Distances to neighbors of the sample 0 array([ 0. , 0.3 , 0.29, 0.28, 0.29]) From eac670979e82cfc1a178ff322670fec022938b89 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 27 May 2019 12:17:35 +0200 Subject: [PATCH 045/222] . --- skfda/ml/_neighbors.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py index 0c24096cc..70f6039a2 100644 --- a/skfda/ml/_neighbors.py +++ b/skfda/ml/_neighbors.py @@ -369,8 +369,8 @@ def radius_neighbors(self, X=None, radius=None, return_distance=True): Now we can query the neighbors in the radius. >>> distances, index = neigh.radius_neighbors(fd[:2]) - >>> index - array([[ 0, 2, 6, 7, 11],...) + >>> index[0] # Neighbors of sample 0 + array([ 0, 2, 6, 7, 11]...) >>> distances[0].round(2) # Distances to neighbors of the sample 0 array([ 0. , 0.3 , 0.29, 0.28, 0.29]) @@ -572,8 +572,8 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, We can query the neighbors in a given radius too. >>> distances, index = neigh.radius_neighbors(fd[:2]) - >>> index - array([[ 0, 2, 6, 7, 11],...) + >>> index[0] + array([ 0, 2, 6, 7, 11]...) >>> distances[0].round(2) # Distances to neighbors of the sample 0 array([ 0. , 0.3 , 0.29, 0.28, 0.29]) From e798955e940ecdd6ec7d30dc3288f4f90a7dd4e8 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 27 May 2019 13:07:04 +0200 Subject: [PATCH 046/222] Add remaining imports which makes crash the examples --- skfda/exploratory/visualization/__init__.py | 1 + skfda/ml/clustering/__init__.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/skfda/exploratory/visualization/__init__.py b/skfda/exploratory/visualization/__init__.py index f9855b2a7..c536d0eec 100644 --- a/skfda/exploratory/visualization/__init__.py +++ b/skfda/exploratory/visualization/__init__.py @@ -1,2 +1,3 @@ from .boxplot import Boxplot, SurfaceBoxplot from .magnitude_shape_plot import MagnitudeShapePlot +from . import clustering_plots diff --git a/skfda/ml/clustering/__init__.py b/skfda/ml/clustering/__init__.py index e69de29bb..c29ec462d 100644 --- a/skfda/ml/clustering/__init__.py +++ b/skfda/ml/clustering/__init__.py @@ -0,0 +1,4 @@ + + +from . import base_kmeans +from .base_kmeans import KMeans, FuzzyKMeans From 931e77338a239290a626d98f4ddd314fcd8f59ef Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Wed, 29 May 2019 20:14:57 +0200 Subject: [PATCH 047/222] Gram matrix inner product --- skfda/representation/basis.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 569f6b6af..aed68d5bb 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -2175,6 +2175,19 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, if weights is not None: other = other.times(weights) + if self.nsamples + other.nsamples > self.nbasis + other.nbasis: + return self._inner_product_gramm_matrix(other, lfd_self, lfd_other) + else: + return self._inner_product_integrate(other, lfd_self, lfd_other) + + + def _inner_product_gramm_matrix(self, other, lfd_self, lfd_other): + + return self.coefficients @ self.basis.inner_product(other.basis) @ other.coefficients.T + + + def _inner_product_integrate(self, other, lfd_self, lfd_other): + matrix = np.empty((self.nsamples, other.nsamples)) (left, right) = self.domain_range[0] From a5b42fbe3acd276fb7575729167426dccba91dea Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Wed, 29 May 2019 20:15:15 +0200 Subject: [PATCH 048/222] Test for inner product inner product --- tests/test_basis.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_basis.py b/tests/test_basis.py index d271aa5b6..2bbbf81a6 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -182,6 +182,16 @@ def test_comutativity_inprod(self): np.transpose(monomial.inner_product(bsplinefd).round(3)) ) + def test_gram_inprod(self): + monomial = Monomial(nbasis=4) + bspline = BSpline(nbasis=5, order=3) + bsplinefd = FDataBasis(bspline, np.arange(0, 3000).reshape(600, 5)) + + np.testing.assert_array_almost_equal( + bsplinefd.inner_product(monomial).round(3), + np.transpose(monomial.inner_product(bsplinefd).round(3)) + ) + def test_fdatabasis_times_fdatabasis_fdatabasis(self): monomial = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) bspline = FDataBasis(BSpline(nbasis=6, order=4), [1, 2, 4, 1, 0, 1]) From 47ef73ee2d8ab5e5267881f466ec0507109c8c52 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Wed, 29 May 2019 20:16:42 +0200 Subject: [PATCH 049/222] Fixed conditional implementations --- skfda/representation/basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index aed68d5bb..f82703249 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -2175,7 +2175,7 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, if weights is not None: other = other.times(weights) - if self.nsamples + other.nsamples > self.nbasis + other.nbasis: + if self.nsamples * other.nsamples > self.nbasis * other.nbasis: return self._inner_product_gramm_matrix(other, lfd_self, lfd_other) else: return self._inner_product_integrate(other, lfd_self, lfd_other) From 6dabb9938daf0c9163538051471ed98a4528daa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Thu, 30 May 2019 20:31:51 +0200 Subject: [PATCH 050/222] Feature/logo (#113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add logos. * Add logo to README and index. * Preserve only the png logo, until we have proper vector graphics ¯\_(ツ)_/¯ --- README.rst | 5 +++-- THANKS.txt | 3 ++- docs/conf.py | 7 ++++++- docs/index.rst | 7 +++---- docs/logos/notitle_logo/notitle_logo.png | Bin 0 -> 519286 bytes docs/logos/title_logo/title_logo.png | Bin 0 -> 574995 bytes 6 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 docs/logos/notitle_logo/notitle_logo.png create mode 100644 docs/logos/title_logo/title_logo.png diff --git a/README.rst b/README.rst index 6d7fc2c9d..ab44ce80d 100644 --- a/README.rst +++ b/README.rst @@ -1,10 +1,11 @@ +.. image:: https://raw.githubusercontent.com/GAA-UAM/scikit-fda/develop/docs/logos/title_logo/title_logo.png?sanitize=true&raw=true + :alt: scikit-fda: Functional Data Analysis in Python + scikit-fda ========== |build-status| |docs| -scikit-fda: Functional Data Analysis in Python - Functional Data Analysis is the field of Statistics that analyses data that come in the shape of functions. To know more about fda have a look at fda_ or read [RS05]_. diff --git a/THANKS.txt b/THANKS.txt index 3733afd16..e5d3abfe0 100644 --- a/THANKS.txt +++ b/THANKS.txt @@ -4,4 +4,5 @@ Miguel Carbajo Berrocal for the basis and discrete representations of functional Carlos Ramos Carreño for the design, reviews and supervision, and for contributing the datasets module. Pablo Marcos Manchón for the registration functions, including integration with fdasrsf. Amanda Hernando Bernabé for visualization and clustering functions. -Pablo Pérez Manso for regression and related utilities. \ No newline at end of file +Pablo Pérez Manso for regression and related utilities. +Sergio Ruiz Lozano for the design of the logo. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 249a18073..33169621c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -102,11 +102,16 @@ # html_theme = "sphinx_rtd_theme" +html_logo = "logos/notitle_logo/notitle_logo.png" + # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # -# html_theme_options = {} +html_theme_options = { + 'logo_only': True, + 'style_nav_header_background': 'Gainsboro', + } # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, diff --git a/docs/index.rst b/docs/index.rst index 9e1424dba..263192774 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,7 +1,6 @@ -.. fda documentation master file, created by - sphinx-quickstart on Sun Oct 22 18:46:59 2017. - You can adapt this file completely to your liking, but it should at least - contain the root `toctree` directive. + +.. image:: logos/title_logo/title_logo.png + :alt: scikit-fda: Functional Data Analysis in Python Welcome to scikit-fda's documentation! ====================================== diff --git a/docs/logos/notitle_logo/notitle_logo.png b/docs/logos/notitle_logo/notitle_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..2be244576a6777f4d73511c6be0e0c8a6abbbe2b GIT binary patch literal 519286 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 HsY0F4g6m;Q7;7 zmG|mGc+N3ezcx(kPPux3&3Ux@9y)}Hikm*DKLvE68?0!21n>lw>ACt>xqJK^YNWIc3hU3!oTMF#vtvaeaQr_#Cc@aTkbUCu(}jGU*1uu%Fh=aI$mX) z)_9(8MkB(Y#HPCt2b9y+G+d0&J9V44?LP|~h_s1KDXWGI zlJazP-gnjiv&u8|IHih51dwhxbVvtA352c%xTekZ^(oG!rBNqsr)AY-e}HeVLbL&& zQY7h=P8OfLN69@LJ+}yO^*%>+W4JO#rS+Gg9(n*7!8_k#IOqlRZ(`hz-GKg!mkXEs zlucJcUO!G*y8G?TFzK^RaIC~WmeN9?i6~H5N-WwpI%(;?FZ|C0LUYwp~NpUnWGdi#bH#` z-6wOAazvuUU0AJXQlS1toBpPzw-HHb#YnHj*&Q_}x8v~m5F`YovtiwM>#;6MKm`X7 zGhM`Est5$^@|$^&H&5?i`F$zwEm98)w2Dk6=eZj1Fs0HS}o8h%VcgPaFd^FGUII{xdL77a8be zLWdP*DcXHpHJ^q+@ipfU>P#WR@z3X8?cvXn5E}dK5jr0FrjKSs4rmH}2x;EGoybkG z$gB80ClC`L2vP~pK9gGMpqy*Ge1W@YD`|PrDM6t%H!Aw8I6-6g2l&TrA!%bJvU=(W zZ7?rLdeBPGgFdY#4)R^kbWsJ(RF(?2?z;-u`QGP78#s}4O-YedE{Q-__FitxI;9v^iuCe$ z_ZmI-`?7`SaGMaqTy5@_QiZW^SBCAo`>3KdjRX#abj0Yax+j*od@1$1 z;#;h67594?X)STGh2!Un4(x7S!vh&m~yqxQ)|9Ds$1Kuv~MZ(5%qqG@mR{T#v!@a7sSL-8#{ya*`}`GZV1PG z@=$&L$uqAhlD-$qft9)3jjvW#1lUD?uf;VX4hq%7uyHjKy=B?;mM$ zoGKLTOQ5M`CI5#(U~Ij|O7fWO2SuaNA6283&*G(v*-Ljm2ARR&oOgx0LW%YodD!^v zf7OV-QKqL{ynDw#MVhTG-|qR-Mc#?`XhMg>Pq&tH$UQGcy9q9AqO^Q#MrGO1yWv4##hE`ZO`ixZ!D{+`(}n#? z9rT*r@8Pqy+_*)`V7ooo-Im?wb)k<1<0Lu+bBh1q$yqV`v^r#C1AMDXOQmkH8g$}G z+FPs4pROgiSDiXLgv(kabXSUGJ`?SofXY+C^mq)=*g6kHVdVHad z%;{inR>M(EgzyZ~Tj-3KE0E-Z5pa1+bBGM9v-JJ+`&A(SJ$?QVo?Md9&LmbhSH}~( z@$BuR)xF*@PqCDKkpP!s>R%1eKb)ye!;OWN1sJ$ei>j#K~h^Doz5-XYXV7X6;S{%UuU zy?x0!jZ)#Drwcc9PtW&hfL3Bt0~6gq(~Lpu`01AgA~o~*>4R&Af1LftM$R=)x6Bh{A}qIse-6`_bco@&3Ey z`Bm(_<@hP{`U_9Jf}@L)?m+@U8WP!KTwSiJmh8qO5^;>~2|Q=-ou6T8_Oq|_IVp^2 zqyONuv+0dZ7{C{NW(iEOy$p^Gz<1TJ{r6`juG<&W_>@wFGkIgb<(P=pDE&IW-f=ed#A9}x}*Fuk;35lBi>(%4&gOk=-HJo-M1@cR|`B(Ls^PVxKv(WzNk{4y`9_1{(Ji z3LeH8H<cyT*o2Tz&#{V~gqB%4S7N?BY2|i_hpfbi@I=$a zp!M8Z2it0cN+RjOBtm(~1|Ex_omS;@l5aC}?k%B;Cj6>mAL!*PIUZAP_C&1?f@+$4 zZPI;pZ>8>XohF=4Anv@ceMx0f&3$s<`D&;*%!c4uz{b5T-IHmT4}0s|rAvb% z5x%7^+%7mgwA=`R%sqovuS@-4FhMQ407R*llp3inQ#j~#_Kug{X>ijrwk;(AhQnm>NY4dl3M zd^Q|>vWr^HE-P^vD7N!(@i!0xXXh4lqrhW)j&q{H|7w>(L6pzGw(9u>bv_3Sc#hD1 zrlk;5LP^tJ>F7!Swbp}%$9vx4{!`NB>kL& zgp~U(WZKypSRKC8zjyc%3zSbFWZ_6HkiuglsZTy&vo&8yh+y0)@GeC^)5J!Z`o1BH z*58LaC@u1DCdO1;Z!UG$E=468-!lT)t`7nSk@QXP=?^qoC7;$8dTfhkrjb4eIdcW< z^nRv4vcb=-jZHCT)gmog{HOzGo9^XByiVkcvO*e1(~i^3R-ufptepA9LrZSYcY9j0WaVX*?qX8@uy4irdNpbhl|#=Y?3xiRFtERjq!rWzck`%!8W5A*e+0;n^fzcw)L0^kb?Y>z~td;K&IB?TX)3SKb z@BZa@89|9nMXpSASLYFYye{AGOquE6*R@9MeMLP0(IR!@f$D;%a^yXhI-8t+=O zLlITAw@3fvw-OrnL9ou(E{OjVYd*93To6+KQ;C7XYuU-ZGBSE;S^42Hpr~36LgQcEfmNb53EWk z7+yanvKC>+qlR52{vOf)%t^kB=1X%w%ejN44pgg>bqo`my6XUb7_^BJfTsa^rTCFD z?|gQSCYX4u+;ipsWFmk20o&?wmy-A~=+PcNk2rcOf~39D$?VC*!;qP-`2j3q_f9Tv z>W?!N)DZ&q?shQoQJiKoL6fe%C!lh#)X*dJ<)6TcetjcuP2?-nDZQhgfjMqv;FI5p ziP%mA>Cu~mDye+2Zq(w2FZi-{a%8s7q)tk1*>DkEa=VJcr=}ijsdW%U9JH<%d$TkU zr!RhZ>a?E;7EFp}TB8cw?8sr>oT}UXq75Y)@{OQS5Eu$%PNsHT&zNyl?dRy8{9`=b z#an15G@bAnKc+I;+OxAla?0s?EWQ0CSO@E)w>67Ge`?xzE$|eNIU0Vfcl{hplLW5s zV17!?FJ`w-H#%@6!{I}P0p z{Is+T6sQ%dLvhB7H8i9?NxL-6f&#vvYeq1a2u0ok~()Wuyr_pLudazTKT6*B@}paEsh*e3!6t*1A~N z%c77d%7k7I6S+R$Cx-2xX%G#~qvOMdp0LMod}mH4VEXt353sw5qm-qzRbABGBWuyv zk~CGT=LpXS*4GQ_=nK?;w`Lknv1)6c6PrfTv@IkdON}%grOon?lq>kfxD;5B8gi^Q4|~X%wSXncn^+Lran3HV7=w6(_NxY^c7F;?z)b(x3VNpjd&lVNw-CBtD4;!%c zIq}WOd_H$bqSwC|D8*)QpG}Kf-%bI*Z~r<7Z~_E_J0s1yh)#i9ftpbL=7)=0EgTBY z?VgPSYmR<`K9g8=uOoNDZ8xnbhaTnngzX=DvGevm%58=A*%_0}iPrPe&&F<3`qKQlce(Z&hD!H97GW|%&C^pke3M(q<-n`~KPzWa>d2|q7taEQV|S?W)aNZQaR zR|^7gzKx-b8C|~J-rVP9sz&16W+5#4WNM~z)*YQ_9Nq(B6c)asacsJh%DTrVA0B{w zFjL`d#$TX@CpsJy(P?b28yhJQ0^Yvz>Mn;GsYy)uzvP@$k{2wnd`RUsPUAy1t6FVRJ4NoYmm*XIHE#Zrfm#{)(mYX{Zdb zPim*&9TMxy!DTiBKGDs2mPKNYQfV?6aGDmfm%L;;qvf&Y(OVu|Ju?6O(AFo%QM-X(I8v{{fg`X#+ueYL4@iQ~Pn24nskI1Dw66nn4 zdGopjdz2?Y$@k?oNn@nRRc}o7ocFBrtPp1-R#ACgNpXA#7$#&gS16$F1Mq0LkVC@V z=Rl&r8L~^hXfuc$+WJW38_H2}OAsa!Kh0TS>}fdua}}Ubyys!sX_Hd~&2_tSuxFRd zw-#$LG46&p;-UrC?edfqRS;L+=)3;w9y z%N(RbIC=b2Mj)U{ZCfYdlait-_Q*uH;&h93;mXIF@Fi<@_@s8Y%T}TdUvGtyqO0%= z2)|=da)K1P@ve{v*H$4j0{Q}?0qy)9_an>bT6?;?UFI1VmjM5AhU zj+%L0O`?{*C_BE$PNYKw-nthjVhawhcF|p=Ua$bu3A31Z$$S+rvRXaPKUeHl;WU3p zyBewx!LhoRJZ-)%TQ;PU+v6w*Poq{91#Fuewlb^~V6$|!etBz$uTK<+IiGE{x)s;` zqn-)kR^Fzb9E~gR(r;PhY?IAW9O|{*A=^iV2YPSi`Tl5M0TO}7Wm(;ywSH9g{MW#yo|`txQ}AKdh7+i>b)Ss zK21^Z2_%ZY6gv>@c#~uE5H{E>KJjk+pp-hjZZnW8`rPoLf|Xw@yjhh=%o}~e1FEU8 z&)cGY=eF)~REl^vEn;u95`4-n9_$n2_hJd)KKB#E1FlIlhE|W%POssKY90ePPY4{; zjUupb2&tN@#w$N~cr3*VDGspK@`X!$kgrdQlUT-I*X=OSGDru}x0tLOQxVyF>=90M zI#=9Tx++}b2KCvexXm=CkwDz$gU)=mF_bnG*vB%nvX2HOh>?KHNd|Tb?RRN|UVf$u ze{NT46JAwG*7GXW*Le4wqa+9&JTurlgCA=d=-a&hDu`3D{=CPtD>H;{>P1%S+o>17 z6LD-wRDz|nZ-u?*N{=X6vb#FYS~~n3 zZn7s5_qCQ{_|iy6*jN9Xd6c6x=q94$(71lmJ8CR z5RQAaoV#b6QatW9CVzge0`OxWGFNP*9?v zF&lK5aqP90Wzpuo@A9IcN=An&GRd6`Sj?;CMCpnYnoHhJ!GfKRLb9-O1#!ekyx-qa zmMNQ)jCU|PEB$s?&WGS^HJb+n?&xe_f=B0+{3HaJyzan$47I>iYjS81<(v}C+d~X~tK3F^^dNf25wEQ6W z2~o{mu5{XIx5zzcXKu8&(2K0F&)P4lP34P&y%BOVsCx#tuus0e65)D_>n-e{us`sJ z(;N@aeRhDxURQ>05s4~V)3i$rquAkWHVgtSQIsZk)$eMF|MEe_dZ8pvS06Z78@^O= zA_82~##U!`h{zWCa1v;|b?qxB7ZP&Vw=e2^EbYDiN847+PRK4vE$UQc#e-|N*7*J& z%OcvRni;4?=<-Mxsj7@cKlW zE=^vgpTFr@jr*1N>YA5Mct|+eZbD=a^R{)(!|uz1zvi9LUVi}<@MlS;?kU!oPSQWW z7@F2-9xdqXYCAA7y(^72*VN6hF)S?s5@{(N_7v^0Hy`G!gWN;jjeO>F8o}cS`Dg!H`!p zCo&=@7-8Bsy>Ay80F-%VE}1607iLS78|ytwg6#J+NObOcXzjR!-h?F*Cx(y zdq2;6&UwG@UVrchaP56vbFDS!m}88!me94$F`{gd$t=&`uF}cQnevf*`TL{~?0vkB zr}Ifjk;h=p#oZ52v)`-~*V{$>&7+4)8X7Yxr&6_E?Np=VKbhg2eC_~O%!uN4T2dQWpJW+r5wpn}BVto&d}Ntf>>>gBUqG`(G% z*U{86I1ZvOe7&IQl!?_Atdu^aIe{@we07|s z*7CT9jquR^Fl2ZYOj&G&=J&qqX@6d#&K_M9>$C0L@7UkW^OJaGi{kODvL}HZzWI=e zFIyCdXZ16Q_ghiA_+D0h538)6+MN~XfDbYgsV)BcT2<^U$TK`#9vTzEyn98usLz`(h}ykr38P%N7g-X5iY z*LJE$ux5JNJkad<@za8AdSyI-7xWinkcw-iUM_O5-BUhVf+4BH;a2q*!K#0FbKS~pgh zM$4}OjTTIem=n)`>neK+hmJ@A*)^<4ETrH!nC)Sp+h;BY1Mi(u6&HSa=%3Q_vS)Wp zrq;+>SYgVF8z-B+T5BWrR8OR6YV-+XZ^H@fOc_)hBGbQju0aQa$`}2-p~(kDcDv+% z@TuE@w1)RQvM+_xlSidbELd-SdAlR-9%I1C1F~Bmx6y^|U}7{F>vE8G22~ZVsCW5e zB<1F=LJ>^g+rW?=3UGIcb<%YmB7NG6_f0(_Nu_AV}rC@C-(_HTZAUp!-J$(Xw^$}=d z-S3GJ8P|2JSYq`M)hhCjfa`0|5>Sj?*FzY91cbdVR|NstnmboO)N`H&UvU@1$CF(! zYUOPdj5*;46vpvjD=^5&?lX6|Hi0TcN*@C%hn0S(-yNBIX|<%3WJSW+niX!TeP{eo zeubS8iJ!OMdj5;b=OizuKXixJmOZr3sq=KW=?aHrTQ+xSfn=jyA;VYK(Hp&Tw$ExG zm9?VPArQu8cE;U%TP8|RzSjbM6Y?L$qkp$8lBwAeXhMRHDti-v7k{c)=!s;pm0=vN9* zvW&eLYSRI|QcW4=y_a&MqA&;~L$&vaW{RUqa|9hM&Go3SER<>pe-|batE%SNRsTzw zlN=}b%cn`jo2ZuQ`?Ns5FO$Fe{WVn(bC4m-+M8vu0-1?bdG+VOFzq$%KDI zN2gB$X9?_cv9qF2EU+rw?bevA4J-#9a56rRL~JScl3iXcZcqJ*hOo5i_NrVQz(iQN z%p#%<+$+5N#peZQu059GDC_VMud@*4fw&asLlKVYsf?e~}% z_apZiC4SWrOUUy`;)R6_-G3CZSL}S{q1mw@;FXmu-jDoQ=2o$QgYkWX5 zunm9RR7YC`p5w$Zq78dahyCEt`E2e0;iZPqOnqj! zMud@OiHno;UFG^j^yF1}FmFYn)g9_B(eZ_xY3{z<#j}+W6g7M1J#SPjAEX1(qbw`; z)W*cCdOf7F>gL2filU!5aZgefY+j&v>7 z`)@B_Qe!U*;mhl*on#p#>KzS7}9ruuJJ*%=U-Y#K7UA%fBc~3zi z>TtVW)qAhsjqU1siTb>G2uIg1}2|4q7L67tYLVtX4oK92# zS3Y{8Y8znu3mHUzHvMR$Cb1ZVIlrKJosAP|e8Ul2=AU<4kRDIBa{E3J@!9XHF`wYy z7VW!#;%b_~==wNYfr`~a-FGxQHQ&M|dtQPL>JCpFw&t2u#AznSXzwwf?6ALtM)Z-l z7|8G9{C1aq$(Pw=JJgP~B~Q{biGI1DPVDi!MWo=K&W%L#91du1@3=iN06_ls!}m$` zgcs0kw;>}>1IN19a}ReFCW1TbSE*W_Xd<0D=pq<7vSYE0TMB8yTX#L{N}R?wi?E6}v&A zJwg8X@M5rFUrhS(jVDue%UaeyYq;}xk+3n{8qG&{4%n&bqdb7chbyeIem^ND&O3Xn zGF{-Oigy01JP+#XBmllhsm5dZ1k?TB)-e2cS#aobU#X6qtY{-VKeaA4)p(;fx;x;u z>MHk@gvv^vUOW1en_gg1=JjbtDn&7L9%dyY z89>2ztC#{HZ{DTPx5H_>YWCSMp~U)DM@BZ~9X=JQiC<6bkbvUGMq7gVT8LfLWjd~^ z*Jp3;!1?_0-mwJ7M-qF9S{dpNPp`@C3AvX-nRdWp9qIfy*2mb7ecfjfi|wA)drUa| z65#HB_NbRlkLo9~iEa}=D@tE5PWhjEA!37cZ2%rrJEb)EennMAq3mp-+<=3BKL1? z8>SRo4@NiKEa(tZX%IEsFpNqDZwBPM25F9&{@A8H^1wGLCTheKJU%BqjKTRv(UaG- z8cu{O!PZHY8UMRkm~8|`peVfkP5EXGdcp02g=wB_-gxbPyo9T%B$fEQcX(C$#`St6 z?!Gs1%W+iIsV{7gmnFWZp5jPtD#nYi!D-lvCuOqh72^tJT9Sz6+ zn>HZp%82IB|Jo|qrkIlzK$uC>P<(GNFkuoize(;>{~Omg$A((H0R~^#beY&Kq%hKV z-=3FRJ=FPS9A|M+N~c@lC&a#zJMx;@Je%93SBB`Y$L$!QvJUtS;sZqM^ z(uE=q0yx!#5v7rx6RpF;cWvPO>iz6*ltjfA^wdM_W(l`i*LtPtvaTuFcoHXofeU!T zByRG7=t|+4#UP-pHk?1-TInFUe((H#-~rw`-lRs-G4YG9#GxP0Ch(3t?hFELl<4A5 z%uwpwucm-oiEE#T6{?iBl>wotjgc+P2~l$_PHm$alIk7VX7S-%!C66ToAl?W;ODsi z*tn26FH3kK5~)*!cxdDqAn(L4#lB#@)z9{Z{$3)%l+E?oMCv zjVcwOGKzWAO8LS3l0?bMa+Eooe`hksm8aPKRx+Z zg8Iu}oWaV~O8=!3l$w?qZ@zE2cWR&};-?+9^}e$du@$-U7h6-o!;aWTXkYufYm6XQm*do zg6pYucylziUti}IMd8*xxQMbmQ~0%!b4Czp2B*~;Vc`Z6D7-KLRQHE`0|k^Uvro#? zt^k6GpHnu#l%gMfdR{t?V^)J4K=e4a=7^A&+U=-^N7QOi_sbSYB0%oSS+;@{UI4}? ze#}=9MSLTy*);SClcKXiMzNU#Mh~AY?=Tzs>4w^RwWPZu4hvDfA}j*;=Qdorb<7RE zulxqoP~ImM?k;vHOAmI8@+ye zTgj5=*{<9e{kz@n&%ZT1k@gn%1hwxajoFq-R5k9yr7h;1Eew0Xac=T0=M8mkq8%Za zgBKd*&BSq6yY1C2k@SSs+Og|D<7AE( zN7H9B5yb)IT(=#m0HJ#7g&LKag#y^sO=ItvTzKgI4_|CU{7*xWtq|vUO)e4R697t2 zXFSIoNmKhiJqx@rhR$2{e6_FAcBE_zr-2;E8AN)DE^H@4U1I&-zn zfZV~ewWi5G?`)iulr#B2HXz%FtFr|m?4;-OV;_ykux_S5-M^e-O6)i_X^RP;VfvSf z%9&7)Oac52Sp&S*Gtiwui;w8Mae2s&G5vO*s4Q_9rr>Ucy0>hVg?>i3ES$quZ8Y1X z8}ik|u7$0Op}$cnsjX;jKfJU)yFT@(@;wE++HwZfv~-n{+C zTCTj~BeOh;7$+K!hL&_Ea77AF#1~*%h)3UUifFbZQq60c7kV_aK(TCzN}ZzYi;D9@ zTq|Tq4>?tyJ+0%pWk>Mi5_P(qtm<8^ZAs4GTR^PqB44FZ7op-IXP{fa7JUU%QwC`( zbwxy33&6KSCF3)o;dFr@`{^PH_U8pX%Q&X5WEfZ!IG=~m%U2&D++F`n=XMRWhyX2p z>XskaYplLjIQJCyo&+*SwD-&L9Y^HijcH9rzb7VBBpc8cpq15ouf7_uW%W=BBbz6H%5{;y+dw8=XG0z1oA<&fp6l zw8vQx`rdtb+m_KT?LxH_%eRw%ltL~!rD%{y=}lDG3ZVGTC9+8LjybB8v*y&_WyqCGs%7$K6q%L?Ras+5keHb|rM?&iZ#H;7m~v;%ty zZ`GsKR9_+4N5I4wi-8k5!sGw6(BIe8)&j0(XabQVT>!__Y8Atc%t3(2z(L%JGIta) z<7_*MpqH-Hu6rfu`?Ea^TA0TLD{*5-RZlZuyDoWLKQG+IA}WCMemw-8XPQ^4jzwl~ zVL_X=hhD7B$9>tiZ8973LlODR^x^(?*7?H4Y~lcE$mL5?;QyNu`Vlqa-@KjSh~{A;YZ7 z5>6a6GZX!oUoW&i2nXBX;KtOzOGkLxUIkh<3dKcvdI1@44)j*{3F}QI`9q;`9@jwT zhXcXgd3a~zoW|~Uh9XLpN}ktnAaBN0X@gu@933er8nO$t(5B3 z<`Dm0GmV!vlYVdE#>yGL(cf^uTy)zLh`g~^vhBik8BDK*)CX`o0nCK`&Ay@OD>E}^ z5wqisFh=*IW|Ej&;ievilOtm$FY(j-{lWO+lBPrBdq_9l5-pE{&0vB~*5i01fSUm6 z4p$zI;*<3^ccR`tYpwkqeR#<7?)f>yJDQJ z8vC07-wO#E?>~S2VugPmZ z0q;rjIo=n&K!mJW1x9Y@ln8~ zxCL0)OuU+bhS_O1&I_<(WGoULuM|%_1AhTDupdrYLGgn{UK}l+JIXnhwD)cnIp^B1 z|Bw0eSzhQDbGJ353Li#bq|!nCXb@f6mV^>_`50$EBS(wNx_ONJ$aUIc_xSc@LkpJd zrw@cE5XvzUqu%pSQt;L{)~r|NdyiL&!VArs0cT^bcGJ%dT>PSVzdo(azP*JmU|*Z+B?GBIKs5na{CXe`6WO$;E^|*bZYJLQ`T8*bj>B;HK(V=uDbi zc;LmEZ!1;0s(5iYpSvCW~|{IOj_GLmYOOP`&&`GW=*nYO*GV+46wS!7W;V#O6!fm z#?~Xq(%=tN-0Sq$hV=0NYhp`G1!d6{=eHS@VHe-}^&=$aas7tQoq&FdY&n)W= z2H0hWkw}!*Bj1cV<_F@iry)bcwoWNdCx2qnCwh%R^9*{jv zu(RF(+BI}iw})`?w0G+U^Tiz@?YMKKVjCTfRX-_a=W!eWn}rS^Ek5#jFz zw&ZqH;E)3$*4=zpXD;jDjXPyAUp|)-z^yUqEkf*cG`nM3a)&>WZn6AtASInr|5)j1 zh%b`wZg!R)wF%niKlHTlVLPc<>YY7&Y?Rx46q^;e7ux`771@cbJW|BDJZ3fv;Y1Y{ z!NeXOGLnlF8t9iawG-&#e(t}{{KEyVgq2s7h1-e1C0Z?Z(6yrO@gbA=t%~=1$%;PeC`q~* zGK%4-DpLH-Sq2C_KPfh;%?Qnq_=-UL@Ui|85y&FqznZmdbo;VF5%jv*(#+a{!j z)yJZnNrU2ycWlfqTBb<#Kt1rxBKgl9 z@!-Ex@c;R7Ua)gD%3_Ums)(lOxJ6SIiz!Ak;qtTHRKLV%GzG@xQPpyM8c$C?Tf6w5 z=_^%<@eTQ4_v`oZhS65{{EuQR{y~Y_#P$S2Zf9ml&q%*bWMEPtVTNo)lv~jPtO*@@ zZk(=rlE+CZhYvwHp4&05k>jIB;eQ``veEa**k*xn>u|Tmikucn96zcj%Kjcv z(XOIdWW5#q=qdKp-f>96(ZUGNir)`4t=lP$#N(SIzKSfe#GDj%xeo#4JT0?KsLf(` z8;KPtwR)j9Of0YL09 zt!GfhOn>pXSQgq?LfdDEJx!WO6v+p_r*#?8be>_gEEh+pw#hTP=`8{D`u~6I*i0N{ zrmBcqweR#Uiai!?RN@a=R5wYbfuV~AA35Fv4x=FCE#e2B@YY(Ol5t#(f`8sUpw`BZ zYT0{sQ%B{3sp9^bK2r9dPeVd-7fN2|;CQ{(#nkR7Yvv6p>odR|QRcHGwL-9~PQRhS z&Nli&2iDN*PBNC(lAR}7r?Fxqi|Gs=kjnFJvaz9bYyJ$Z(x-uGnEa6^o!7Kpd`-d@ zj|I@+<31QRJsaoS$2w2*jRdukhhf76TWgtlJCI04U~;T9N${smqIR8Sa*vp^05AuS zM$gBZXgN@x+W2vJb&wC~7n64OqWjdeDr;y=v-|7iqL^ZpN<`sh)_nv@|J| zEb7DrlKfsfvV<+O8bV-FiQgjO2UI}#n3I6l!shG>qucpG^^DmJXf%rf23xCysLCTR3N zt`bmReR_NPmQ|)T$69~_C+*c zs2?M8z@Y*lu~G?1RBjf@vUKfif4X_OwOjcSUicK=ADcy1Y^2X?8B*4Y)t0#R2wBn? z1Iqe}Bf%O&od`S6NSYC?$?+9oI?Q zh>WoLT_JZTm#{GL=lDhuJ>|#dY6E3WfZ|Lp&Q4bgwi;^m0kVZtD z&F7^-&E@ecpY3GLTNymX0{1__++&C+5m&x}Q}luf=nlldr}ix(Mux3eS36kPz!6R| zG3_$SiJ!U5Z46avvfkABXlMLOx~7;|;S~y!GUVb@dQF?sXM4xq2&BBiQH4=#3LZ<~ z;`srnoi!@tWooZbfI#!7AN!qVRAO5kneUpT4i_J~EHiGmKOxfzm%O2esPx9O?@a~8 zCD_r64c@tXttWE-P;aEya@w^iKSM*MIkIH>d4$M*MMorlY?s2SbBAoUntPuNtrk6^!tfc;wB?$U6G~A9; z&njA}0!x_YzZ$WER8FYi0FUgdld5+Fl=9{hrcOuuX(~O%xO+o4E11IYdS;=o{2Flv zUhLv;O%=)cj9UlEE7e0(u%H4VJm|tL@Oq;|KP$d$^ay#psc&2$z0Ne7IqAJpqKn!5 zKJua$E4wJy_jgcN*^axuTMhOgNZ3x!3iePl@sX!D>+A0B+(*r7x1Im)Fi!gj_DXcW z+$-q0*T3+z)`y-_S-)BnHbv*<{}(37B91lR~x0K4R9iys0nQm9r!ZM?q;KeFRFnvwgScR z_`AazQj3LcYKFSOmBYsgk;mC1L$P`u=QM+|R9ucR)tPSFA0rtUfV~z~Jlla`B@{e~ z^3{v>8nGcnn7u3Xcw?5J5!3!wF{8*G$*^Bx@&hAl1OS=2+EO^ zY4>PXX`*%K$i81zpPrZ?So@UF8Lk|*#eWnc%ybo-39Gqd|g29Z%vx@_x1h)I}Cx?P&%HvKP0SpT7 zJ4L4eQ1F>OR@R<=?tHkgSy{wl6#fJ<)T>#sPOfX4Yh46v@`S-HCF0oA z2Bm54li;7Jh)I@%*Pqw3bqhWz8}ZjlwkVO?JoVOl7)`zA$M5rrm-G#e2h%22ZsW|cnf_z(RwshE3kpvrFXw~ zxU=~aop4A}szyUs=DAzk-G47w{z@^LnyQQ#82CaoeSbIfKc^GX%2AV9-vS>44CB{A z>-l!;i7Ty;-P03{LNXZBaU?v2asHZZYiS)F&KPpW0(KN<9RuKY$i;cpT~l4PwPhXI z)uC!T)*Z=)kl^tlK%&yfVXQ}j^uQl!v*4WR?p>MRY-%bTh>dur)IQB)k-g!@P)@%g;Y+hg1=@{gZe@M;Ea$%X= zaSJ0fdu-=;xRMSJD<(a8E`)5|t3aEi&^P(BxXDi!@7(`GcMlk|a1+t~==11r6}FJ7 z+k1`M6P;WFOb!~k+k`Q^sqTLRoEHo^r{mgETS&h+2tBzD$u6>6a&Jb~aEm3|^Wl zu;}S)iDHHWsLYBbC2)cKig9FSO4VanekAnw3%t(vXiVrWH$#FgH~S6Vtch5Nic%lQ zi^KIXdeNrRtdtxXOFxb%_l&*Gu6=%?kvAwG_T4viP0tjg8)4CXKqD`b^@GQ~ke-J0 z{YX{=Q^1OSZt-@!;7^z+k;m6^Zkj4RSl6zPW83!>B~le}?*2Ke)66l1oObC?2hwFh z4&0yQwNJfzYt!|+~&f)5NP*33!cwt}_=(=`X2fvtqtnlJ0o#e^) zZlV-GOjMNfN$+B=bKT30kx*XILIoDQ4PE?z{r}O8WKlO2?E3}I@Vp`@ZM)7ef^vO< zOU}UfU>9oIfPfqQu5-!t(Q4Bnj896~F5ZLi>SY$Mx@Khq%`22yp_N5btD= zp>cmvEC@Oovf}X;x)|CE(+t=+g3zQYL$@>T znh{r9uBDSmT<^$7`F|xv#Yf3{ENx9&8(pqRAw*Wa(jmHWDV=;H605|-Cu!!Ovd@%0 zZ&g!s(gmt zdsc5?IPXaG(mF}BwKVsPK-}=VU@#T)7Y5VJluo|3nz*GR*NQYINI!xc=SK@fxtimG zfAft`CfH-gP2dHs5I>`kH4(y6bt=mJ(NUOe^I7enXue{tBj#^-Lqg6qP?AvO(RvUh zTt%Pxs_DZhc{!b=B_eb-Icm<-G=lOq}<5Az5hP}H43edrIzf=s+_-b%JL`!!TuM6xfaZMal4 z`f+RgIkg8+cHdMStm6$jhgw{GOLVfkc0iED$XUFc4rM4^SR_B4qMtz^D_yXO77D>G zn=yOsUVEPkJn3~dpL(gTm!dK_hfb!TNTdC5i#;SgAmun3=EBh0*ux^ejc7$QSg6|* z-890!CunS|*Q3%3yJ@hok!M;0hfI9^p@YE75pMeIjj?o84_s<%-@=Fnd2qTuD_%de z)vhWJ6k}Z)f<=Bf@-U}iw-vP!)_YL05zjOruu0)RZKSs18&)vU{yTw19cIey#fq0{ zDF1LEAs>EYBaJ-shcLT|ktHF4zGTgcp{pS~E!`7AEFh{`W-Ao{QObF}j!2JW$5C17 z3-vqnahl}SFHaUB30f_=f>!Ua{5`H>yfR?M!cT_3d@$Re{FfdECO4PuZz(eV=vXVE zRuW!a+f%a@+|s}5g5h0GB1c+^97BVdWaKQ2& zx9>f3d0MVYfCe^8VUgX_7_TKJ>Y+DW+2L&4$#B9^_MOn(t*vz$u}gF&f*2dmEH1T4 zNNH0`m-!R5S2nR-3-x~LHeZnRt2J(Ds0O6?RsPlNXxIOB5^5S&?!&vEK*54UBR6q9@y|@v3`iESF!t8j^RhgaoT!{pr}E5|e>xjJxq(e-It>Aqex59e;o=d$8MyIh&6QqA>l} z3qYYB-;B?_4`~WcDErBiQ6(m1x+f_0fz^i8x;pJ;Q%~|Gl^vEY>^2;98*vGA>^~t5DhO}dxyNi|A6lPmw%B2ZRHZz zjOpii4WRL~zNuEx>dzS~*Xk|wd#_)LoN8Y~M2=h=*{hrCNa`v!u46V|J4=9coO@)Y z$8Tdj56(@{=)2mSrmxb&7nWpb=E_#~)Dxlg687O6Jd7rvLT!tI?`S#sfqx9+bE%-6xY_8Jrzs`#(^cFE`|#P9(WjLiSWp1f+Qc^AJBNY z^Tl${X2-=N$HoTJX-F+~&ZaF%lyqU6AOAWrxfw<0t?Sa-U#8~% z55Q`Q{k9gLB6Vw|AWYbS>zVMu`S)TNZyU4k6KNGeORI8cdYLsjEb|SbEz+OG46SW#hr^7C z1G;YwBJjoEhuZ!^zS<;xK4n3o<igc8ylzl63?tqj z{YJwi{%U%GFis?0L_Jh%$0uZ@i)}Vn=e4?-Xfv@d^K@lw=eMM=kmM@D5oa=E9@u`* z{~?9;p78-VoAvrBY8AY%^yU%Gi0VDep6;bPe~)~<1f%CFu6m6eZ<6_EwFf+P+?hDz zRv#WsrZwl}(C`idfn1TAr*;{bTF zjSDa@7#Zj+5$*k1*5@Jl&8ol8{RjPniHt&zJ^?@?)z8wi_xSF~k%*zYIP3xY+w?WR z?z+3BH|gQhirg!WUXa&sO2zl}piL6)?B{QRO|sMC`<+elP{I81K4zGCDehqpCpe-H z+Qr%t27PxN;r~fUIpgEpM^(c{|FmC4LqRH%|74ixc1DC2b5qr}n`G8a1*w#|s3B|C zj~P3Lz#8`)%Qn5tO}@w6UglH3XsJz|yLgWKkEh4U) z{cB|msL+5kvFq?hWX#u=wAYZRqs)$I z>WN3tBt><1I?m&X%fsz$Lhk+Ci{+P`Uk}2w{aysjxHk^3qG-3#SP~g%_f-=j76g`^ z^F?4nCr%b-H_2X<+KJkb<}1CC^|-&;bF;sayFkFq2|bc*mHFykI?32fovBvx)FyYWqkAq_IB(C&V-YG`#hR@vVTM|hl=a}b#R=pbiiEDe67r-x%UaeIPV>I$4 zz_$l0{eo4~aK^zTzT4Ia!7iY>ESSl6G4CWVec};_6&+8vIe6xnB10>r!d28JU8)Di{2kAQVb=|!nqo{4q1{ly#5CVWEI(iXm_NE~~4l3;DQ3YWjCz?5}%Z9yHF#?_(; zO3t{DI1;SuL3BE&?PKef#Ofn!+laFg=>*PJ1L-`uxl-TnhAGMBE@?zfLxJs3QacQy zPN`=iu)U)K*B?40jqzGXPik-eOh$VF4OQHXon@F)XRBpMAQ=b$Q!8DA5*V&<2Yq{R!AL|RujcXNSNPf%0%f=^7 zi@E?h!~fvr^LUlveO1UoGAqsd8&lWPWD(4zJ34G&UGKkq>JI^#@{#~`z)-nr?|QYB z^UL2fWSlACb1UTo@L)J=!YG)d&hS$}@G%Ee+ z_Ss&=!#KbSmkKcLY{TOuM3beHz|r8=+^Rd2RyPXArdat(y~74=g)}; zWb5!t72u}rHN z!Weu(2q?e$kynPcvb;1E`_d3{4&m%^^RBwvuU+~X$QQLuU7#zmqY(DzkZ|PusCns8<4Uq6RuP_{kyIs?>iP;dOl1HqZ=Q1mJ7cTW9}E~~(C()PFxUyeZ|Oo%EQzA%$)%hbY;h5O^dn)#5WZ%s z1VUvTYUQu@QGUw`Z;)8~dMT!-DvF1ht>Y#(7R=y|X*(zE<^IX7X6BzFVCsq@U?PWK z_ILe;^u2XXPQFKc)8|Zy4&mkkq49J7jsAJt2QC=;af>{GBLWgvBij@Ig+CWw@HoG- zoA)kFf05K0?qrw;cQ3+6zw0vxJluXX(~;uo=%5@rmp7$K3y5P=XlG0A zSWRfP#dY*@Dksh4b2&4j9eiy+`aM|%Ou1{-48T`jnmhJ ztf5Q8T0J!98`8)Rx9!wGlQE%ppJz#(dAlGR)5~Y_fKArPk)~s}rLWx?+K!}%v*~Dh zj2B!UlWk3%c!{Y#2YYytG2qt8gg}@=uqu7TAF{UiVPKDKZnmQ~QxE+u$3i{Rsml+{ z{Xd26ZT2NNc%5~;)m`q^-G8}8TTzx9f)7V~`sNrCnq-mrbfkk?vKEFPWR0ppy(_A} z5iPRKW+;<^P@da$+Ayi3w#u46whvx#*Sfh(PdnA7ab%dsK*_l%UQxtM9N1yo{bDQ+ zDxd}N&N>vu?M%r26TE1@%^cIq*T~R}HtM1M%(iQJ0$^K=V@V9{v$gF2>4FM zFX{;w_@sT<1l%jf_3Tf`(WT7(XZb(qzg{e(q~c^(UHiOdpe>iKT{atDuRlnt7dEzj z9l11kD9c&C{KS5@->X44Ibg;j$CO-#SKfykQem;vDtfoQCZ%g5Wg%&|jv9YIdtG~CQ zxrPid>5g2Sl;Yt%e;N)NN$#TTJXO|htruB8do0RkSw0c;ramR3(=yagKUc~}DRq=$ z=gYoNpr3h;f!?#yW6*h)#7Whvy~oY*wL07Y%c$Vw#QJ0{ZaQr2A z7zI(h8|2t+uZ)^(fu4X;o?P#ujx=9@6_uYSSmU!HQ?Ws3TnpD<$s&m7+k#(#pi&I-RIVWAn>KuRcDyP@;4^IiGCu&Xj1JW<+dAwJ zud;6Pfp3Kmh6$OfkPp1Sf7>&13<=|{+y1OJ`9dg_0BJ#~qulO^=aH zLrf>rPXdkSrZYvc8JO$R6z2dk#>3Ud#nS^|wHVgfC;$r`G|d?#XXsYh6N}D&e}Js= zhGnS#t;t+BSp^dihp%~H0K(~|DL6~8B$`;?Te(#SUoSIRX8VFR&ohy~fKN3}sb!+PzjcT|iYwL30jl2b!GZ9K3gEtPGIXP4 zv8k8;EcKmMpa-6N>uHzJhYeKQ(zjPFQxX7~WT*KYrP9~UxZ(SyX$d9wK5*q^zXVmBCHq3JUG{Brca{#DB!6+(nWWG z-q7uUtIcBQIVykmQvF)F>Yc~UCIB%xbEfU7x)@A_dQqpEn6CNE(AYlp7)ssb$tTXx za5W-_nJN0Q9v6BFm5se5avi=spG3YeM^3jq$&#(Rv#uU!^qR zETuF(`a65Kzna`!@ZjQoj6A_u>d?LqBuReeuVu1%uc|SFWtQJy^2k@|AE^|WwdjYx zP-d=Yh-2@A>aVWf1(9qw>(L9k&F9y{L8U#N?@ikWBUzAF5XhZ6%XOc~4|G^KkyYL> z@6<}`g2oA-{n+ugT9_x^>MgbTVBX4l`?|g=--UUH%q7lV=FZmA+ge=DmK##~%pNI= z_}v^L*DRfi+529M&DXW7wqy@~l}vCT{lGij+3<`dD`a(eF3S znAh{kp*M)IuwU~7&!;&PTzG?AbY)*iT-M*21|hKvvsJT|FHhHO@vw2iyUdUeo`fR` zGDsQ}UluOvsJ94Th_;FC&Id=y z#aAak-E5wP`{(pUfX1n;9~Qpt#h|5Bb2RXqqsLK1oHu9@pLF(62Q!mFcsy z(pwnLQ+Yl`h_*ykJP)0F{}?wXt0%|wdqqW5p`2GcU``2U2qlm=odAz0X%KOZmk z#4_C8J{($H4mGk|X6LmbfU&jhaxaY$ESSKwzRsluLGWmEp6hN7q@rmtXpfkyu`@Es z+o$6W@c`;cmLGTZz0p{h6QW7k0O6)b*bq)mxD~%Ci+Zp51BuDMJ4yJ^-GW6xcdlOV z#e|nXrekn?eL)Sgt`gTq=LHHH%#^hNN06~>NOU6ObkCvr1&zbQzI`uh2f`!`3Y*kE@+Z8!(}OET>qW~Rs>akZlgdA87?gFWf9ieuvU}6HVF1Va{;x zd#D6bf)vu|R*PwE2KS-@9lwfKaa{U~86D2Do~x!7KwNV$^2ZU7^7Mw93>bQke4rEv z6wA;}ep0!;@8t)LQa3nq>b~}j!jI!>mE6_^W^NW`fuW+_ITU5+bYDO#6k zle*M!UxYzNSG>6XoOFOGA(Q1ewdb8Y-fOAVp+3ZVb(X(-#pLYf-zV>$lLLKicF~W$ z__~?O$O_(Hzu*giq$0m)zLIL`uBri}CUhY03_gwC6IzHsk^pSYr^U2$zIc~fLXXOc zG>mZ%LG)NAqiGgqe0=(EUZl)4!F*9hGyXYey)|K>2Cjv@8jYIse{o|%`*)fr#V6&b zfPGa(S$)5pxc)SX_rkkJH^4iHAcbN&!zDl;izy>`#~gOPX_Vg#(hnBa2WF8LF^llf zZ^gE^b_$@^H(Nm%7%CRZejg6A>KM-Tt>mefPm{x6@EIVhvd{c}-F28eA>*r|`IQjF z5E)U8n^6847~ByQ8P(c8S4^o}ct@i<^r*z*fw(#XZCN5d#CcEmg^P9GBDH9y&!=a= zNf0Exr?-hpL2{lybo$Ib%J_9<&W_LzXVgrbbDNy6s(9BHJuIymKEb_|zhd^Sa{$#$ z&aHM8L9vQX)CDiUM#Tv0h;md05Hw^aKh1R`*KcWBGmz;ZQ(5IfbgR7q*UE6dd-4nw zo6><>XH6jWciWuIIp5&?J7+= z!>#(T)Yh-DVv7la#bmuW5SkyfzOMBzBF+W>ER+tq5Tt%G12^1&Ub0VA13z&2*`qe0 z`FP)*O{2GXdU&_YfBUwt@*`AYdw=LxFNw-dbb}FNSYWOD|J8uB<~4O}HS&3$j){ir@o_X3!*!WH%TkN|n& zrOvw74(EsK0EMD8pSHDIPVbL-=k`*DYF+Q>`w$W3H=^&Tr!fA6DR0k5mq^Cu;`QXa zeot9YbHM98%GIjwo_$}*X6EXPSpDa!^fSsXPY!ATLu4N+SBXlJ-liIc9L_Clwk`lM z6@G#bfbf7eb5+WY2k{2M@(H?+f>BhCTVLzDuVFToC@Bl^pXwijcej;n*x2?keP_My zIKyY_^h|}+BH1n!Z(#%Sj6sW7q3YcKRL}B)*n!L8uqG;U$e$OeB*^%t^?(kFtP0QR zA{r=A%Dk0awKu3Qz5Z4DSXi3pLwnFF?4kAP@_(F2oGPN7-cdV$EM0K_(qry72Fc{u z;-a#u35Fw$O6&8e$WAgVSV!yY9_his`Ry_G3hI(1J9GtUNJ*-JqAGYo`5e^OO$^L* z2GY90CE>Ti#cT4Oe{L)!2_EdGkX32k`V+Mp8ZNa*J{0U!NKwE}hn{x}@1|PprI>}Y zR5DVURO9?08wf_Z=>@Ie*hTrpYKr>Wk7SBN?3-LsV(pqSrQAIv{B)pX{v9>~OX2TX z-mfkt^d2;+M1gbjbTE(I)|&6G^Bev__rStq88cJvmUegk~D`CAG_5I>DQ#^ zSwkH+vwv1axgPzFy*09vP_aEyJ=n~k34bJnWGKJqzf)DZoNF?Wi2UJMz9GS$-S$Ml zHY+!)Hg2LsQ^6M0-1@Q0KZQvjFGj;n2$1A*Q)h-?_uSf!a=`QmM-XVV7Z)&YBrY*f zNl;q`UH(GjafuClcG935DSw6kpZLC$?bFZ#avy8kA&)s@U3Nl_GFmvJJNNuiA1THf z4l1|9{K61Q(32$zv_ku=w|ERV{StBPb*3i13E0t;jsQ$|9+~%S7y5IZu}q_EYzk_^1<;vTvVj9BDc92 zzINF)|IF9wg7apADd9MECud=C*yn`2_977=w`+>$DXt!O*(g~TkGBAupA+YPIBTUr ztlDqui>D;NE5jH+2EAFk3RAQSrlZq&OLSpY!pC-AS+vLm3?;F%KjQ?ePe~(Gcsecb z&ZEkz)CF^^8i9N#uLf|8(HJct&1nzVCJ;t|SNbfe*tjoGPYIjv^z>h6>N=byjde8h z*>ohGSyH_xbYAp9XJjp|c_@r7a-Qg1?!QkTswspQ7< z+T@NxwSWNeD?vnv1lJ#PNB?-DCvj@n!22*j&*$XC9c|f0_u|nWApBuIiqM??h*0jf zL>&evuaS-chY5;_JfAL~wie=g``Jk#ec4|QiPcRpv7XScIFbr+ma7NhkdDi z-&>g-*KN4?3tW}?!^6Ynys?n-z##3t9GfFL9iV>e&|tUz3bat{p%(k}d~R0MU`-u? zP=TUdGC+sV_gmC(KnoCvv=qxf94!E<6Aeh(WqDxTR?VhtiJB{*>R}AS_)ko=)7-is zm9RrsA@k%B&|3qtKwf5jB%D4M0_{(;!+%lRDJq}Yz1S@Ay5#4mmA^#;XiOpjP3@cO z%CNAqYt#+f>Z#1;4)J=yh!fG+?~lDj%E*hbEWnkml+GPgbI*3yfkPMC<4Ab~SMNB* zo!-o1H0+s- z{zcBk&+ua6ilX)=2dGLYI<;KKrcpHoRg-LYQ;!O_+LsTp4wk&#IEQNYdxmp_35d)6a7xQ^f|y>1L8Wc72EeOg>)^7d%|(l zx=kqqpTHIE_=?-%= zErX$Lw+L%5<;2Pn;-P(;eVt)g$szqLXDXE_ycdg54SeAWC(?P@Ad{cl^CLAOHW4wM-ENnUle zJn2;%-mzrnIdab72Q!&P1pf`JMg?GJ1AGql+T?dz>d7AMF;2Yb<;QKMn90ntcw(H2`}zN$)KVc&ed7 zu2brmA}z@?NLE^+DPzx%e7V6sujhK`TR>0yP#qYYp&Gr1>t;t{@g85|18MJWn&T8{jfGSQvMLa+~S>JigTJVNPThiNq# zS}BVIF9XOvroWw)DfKh+u~g{C`1H)OApc{+&~UJZIp76g#V*X>%_~pE*ap&$S_7%r zXNR4ZX+p*%HORm3zjnG_@Su5W82Mi4A3Y7283!E!U&v@Tx#=V9qpP0D(hq5w2oS#y z;Nbs-tfnmO=o}G+af9XB-m8#gp>vem4rGBe4|Kx^MId~9V9_=zRWTVBv|=C^;+l~X2A081(--CXUB_TOxQ9!-n67`H=Skzjxb zj97Np^=Oi4{GjEUm*aIJi0TNeID@|i#{~40-dEEe<%p>ZfqU&_kOLW<6*eFi2(jR| zfTmBB&O7wHp1-ye)da_ZAlW=B9mjNfF28$SK2*&`8)#>ap7$*qhs|UF?Eltm?@HxS zDW4D|X_N#h+CVr}Is_kvl(%^vb0J<2d6uTjr>~fd0zm;@3c!VQi*sV$w>iGo*ri}O z4IrSdlsajQzy{&N^Wfr0$8_C803m&|MfJwF>Lz46n$vE5PYtX*Ih>PD^QNVH=iCWl zY~V$iP8Z#n(fhN!DUaFXKw;r7BH-ml0S}y+wop zu!aX+khOQ^jg%2|Xlz^C!ZXc;&+WyCKdP9BNPUQsT`Ldq=d_0-oQN>{sXF3jjhE{0 z2%HMPcolQnkj~7s3x0xQxwwYv@Fl9QhxdJ# ztQsmC_zv@}6`b4lbBaoB`|9U~M<04E9%Ylgp~|u$Vw%<)R%qKk^c`)Ua#5edVwmaN&ogB))-QtaV-@)#%|?GIl}j$(J)>vBwa?hyI;&&+NLK zhk6!AzwYVAFxg4F5nrkCDeuko*c+d8e&nI`ioTG_UVQV|n)Kh-F>Q7FtlHL!Uk)xh zLgsewG7x7b){XR@hE{A?Tfe*lfCAeOAO1)#E0{K zRT%2Eu>Esc3filfE7`hzmrwJ^0H^U%9;v6B>ZgzsSb`W4Yp1WDfd*Qvetmt9@H)^M zGUX#=k~H5mDt^{6Tl~sR75KaZ9<;@J#O7Ga{mb5PY;bHKijhU#ybNGQ7_R?s9M1>k&3Rp&+2foZ+W zFfqkE6iA!qfV=LdD?2=SpEc?XiIiH zjwf65n%j1ai18mvIMEc2{1bs@*&hRblBhZmVyx1C}_wGAg7k zaua{V-7`2-%+=SW??y`Tn&aaPD0Y}0 zgOg#|P$P3C+ShDyL+WzbU6oq@cjXVy)ZN^tTZ=89cXRjARH1A1oLFk_`ZZt7vZ_LB zgxTCv`=Eh845R}DOy|Fx30b1Ru(>umiZ8f@ASkWyc7_Yz?YGSi{mf28HxG09_PBL- zBi5H^Kdq1+TdsSBayiSw6|(_bV`J9wxYL4ex!7vJo|)sxr0}bFIqKHxh{ydY9WLrs zwn;A4ot9UVo96=|a|L%0UBD3Q%lf3^L&B3_(z&-&{T)g*vXWDge8I8FgWjs3P1Cch zt|{<_pqXrjA{`IgWb$8o0`s2uurN`AM*+TqfDwsK-6!h2jg;q~BP^ReR4(OHIltgb z4gNc(1Ts{BJR{I7-E|EUKLxJ=bMS;B%z#M~yMlg+^4X&=DJ|h>X{|brI;|5a6n0UE zSu#0!cymtp>`E&dz)_#=gz?_IoBK3f-fl_iL9>`Ya+1mWQazV7rw8JzTc-N=hUPTw zscT_g!XFMRa(*N_`&A~)Chb$%G_8CcFnOYG_d$76f6=;{>=zN!WGpJ8J!q%2~1ANy9Vo7AUcQ?0W~pV~s{3caDmA z-ZDMFSPraw-G{z~biw>%Vi#r4F-|*c+6T0D8_wIrD~i4=P0clRTGAEb86n2<-so-g617ZJ9ULoGZ}Nc zY3xQ{LYlvFd-t$=PenW)BnKP(iG*fdy7{6n9T7No`}kld^$Pm_sp1WDuH+Oo;wmWw zEhcs2fr5&Y3WMb6)8+6YK(`;S&3vg0zPVmaEeZvnFyZX1yl)x^uGbv)F(SjwU({Ys z$WA1%Sz%6cBy<*$a8Ji7%1!Y+wultbznE?3mdw02FH!MHW0XYpU!8ws=(R+8e}TG2 z`pwVi!W^58;%YnVup+x2%ij|E86!T^q@wsMdjpQRNOrB{(cA7zAM66rCchmeC!%hF ziGJShi|W`3#W*vkxfaQv{H01KO%2rt50(BeWVIid+kt*Ewt4I4G7DZgE>-vB6ci30 zw~ZZf?NOkYH}lGC+mAW;BEv_Y`PNhOv=r~3u&Vcd02%#!Ci0x~nlBKTrJ3~4AH8(T z>j5D=XJB-_aAJn?u`?YQ10$BnclA(U3?p^A|NF6@o0ZJmb4{@JbV5lfmQLVV09tsN z5dk%O>&#b;hMTiSP=E$geJOGGJn8`NtM-V!T-Ebg642Ug>g8j*05`(g9y#jq|}8TfT)nvV$s|u0{Ty zzN1JqY@4@rs7lI2{QgTTYWQ`}(iARVg}vPVK?bwROV1vSO~6N>Fz1VkbIySpK~(NB zO`_2Yr83~Zo^Ag(7k?yMyBI8{rINkzE}|ua2Xs^ez0P99#rcLqSisSREo*xfLwuyHK|k~A|< zkG59}_aj>$kBdJFm7i$sr4e47>@4?aeT3=C)EmY8an6w#j=t;1TU+w=!JblOqbKY1 z8F6xueY(M&)#&f>ERw4G;$HtiN3=Nnp~IEq`QDxml5o4`zBxj16KkPQDc%*`ZmqSK zHP?gK!@c9S-s`;<;js@JzsGJZyWHl*B;|N)tm3yIA94H!-I~I_Y-l!De%1375!%j+ z97H4vAXQ{45}d0_(EunP%d>Gt?9Lmpe?JkUw}Jh)M=~V+$(q~BKeTFwpqF`eQ23+w zkPkMd#^~X*b76LPidI%wL;6cu&vy8dtbUoY1@ z)u6f3tMKzBGHCb9wFkEUB&fe`BjuP{^>+h%kARIw0d< z$>kzzLXGlIwGZyntlp9V)D6Nhe;9$V>pMggbo$&X?K)F+AExTInabfX5*WYR8ohmV zNT$&_u(!)gf*v|Dp#UU`f6GO|cOKcZt{t(*d;P!nq%}>PBLYP}B6q%F`L5QFA4ci( zo~@gL_`2{1+5k#>qA;7abNI*N`jxUEsqpFsD%X+KSOq020#EKfIK4kTmN0?&dRx!G zo33LQ-~27DiK%ONmE2|{cqt-$k1)p#{#Z3y!-_xSynqu3gLuLmkIV4V(MBhYV?5qx zHW)*<>b+``_edEkA~QvrQ=~)I52pBUj%&_b8>{PnbZGIM>7}_djC+_IzD9vw8L=|w zj4S15Czy9=Tvlery57ahkvPsdqBgYd45{GiU0jB#VnaL&966j9p?-Um_NEcGpCH9s zTEyH7Bz)b1?_N49_tlRyNM>Yy;k)v2$Kz;7Ck441$F+#r2`8&@KKF>hc*8X}+nJYX zFwmeo=j+?``zU>#9Y98SyKXX~d=N!Wy!&?Pl`E?5mh)qDPT}6y6^qN$1YdCaaerCw zxO2_U)R^wGI*I0oGpafZ9?Uii3u=`;JV(@1I=5JfuISp}>If?u-H&taSp!hVtMxef zt?Q`?S?9$dr9O94EUBj5mlmOPPYs8>--IVLVyo@1v@hFCEWW~HiGAW3emAx??$9@1xHr-h)+qv>?Q!M zR8ihOgR@ksf2D)!Hk1;vp8)Vg{~owapZD!xy93j0Tf1j_k|Gzoji6d$s#hrJ`6a25 z1CyzuErzvrQdk~l3droyOwxkqKD>A9A0jHjrv8~y-@8S=P2OjoNYk!E(E2XxH)i6 z1V1bLPIpq<$6YmooGc@#t@cfHPD@xrM)sc>3G=RNQBR#_XMj@e8Axq^t~&SkCRHBG z^7$5w+|Kl@WNZH0cRjvTc-2fK`aDgHu$L)UC|XK$J<>>gzL)24ZZgQ;^wp-XKQ7tu@y-_ zque*=oNmJDk1R@L>WN}D(eap4B&y#%i#%(r%m2|aOoK3w~*l#2cIA zORD6JqIAbSdG!lVM(Bo~jI^r`Sq`6TU24e>^NR>-P3ul+p>^F|-=B2O=+f3cKvJqh z+JlZ)DL`p|l4_+y`U>r8l3cffdFUj|U06Jt>6U%B@mEI${lIaZV>kD3CO1P|P-cxl zPTgJ(?yZN6%FT30kkoNbC%A74%Pj{3aAM<|3| zgJnghlGSL2pV$H0PQbXA>!oeBFHZ>Tfk65OH9YnHEd50&-yuc1PK; zGFJlZ3-sgPkaPIG5H2gSqwTo0-F0ts$u&{j1`4;!T--C1{&oSDvq$ztyS3^M!giYJ z_6RlvIq<}gIE(-L*d3I=s2FU9Ck*y%{XHY-ZR~N$ZZ7DQ{6+50ee62Mf!o-hU{y6V zl@Zse?cx|^iJC)MA3N?&6h^Ey+0HEKaSOkLfRB@ zxs|7j$NB8=KK{6+oV`sZ)NbM}Zm4kbyC5fKHhJD6T7{Y6=yqr+b{uL=l_hw|2gE1k z&31C=mRAeL<^Aq&miuTx_|KwIMX{0ip1cmJa|LSh3!^BtEv^+W4^i=Sl?1R>22KQD z_cQ0S1a(z1KIRIKOuf2vAc6vRyPqP-@H?2V`qgr~zo-y-Q@@(qc_$vftF>Cy4E-XL zsG>|ZS$%W8SisG+97i8~JPmZS&BLKtVdqVRW#50sZo14thmWAk{>O+roLoW}SnRNU zyOPH<54i8+J)XrT3#(9Rw*HWI(*6+d(f(R(%NxDcapVXAF(j*Bog?XIe={CA<0{MP?6Vd_HpVUEZ`8HWO_;L)pejP*1=6!Tv} zpd=6s%cf?NF}j(nyX+10C{+4{jzbPvOZ`u$lC@aU7Rh*Tb(?hT@C3~VcQUSPtj5hA zxTbKTK*Y@6bziHnjXZU{;)!r0L#AVpGnp=AV0x8O5xeSY>=~Z-)zz7Jqsh|E!@h5} z^+2 zH(cqYK&0I+ly_VdcJ5?zCJ8qIS>o=v9D%cd24oY6E^n#EsL19*?wf_;p<7p14&W*e zH^XKwi0HSLo3#R)@R9;biTx5|3f7NuqCZY)g}9&D{j+B$2yGjw*k;hCt|6l&42UsZ z0o0c;d zA9L0L&!@v;G+6M#R)DUTK@Vm{@p<1`h}^x^YLorPGV^KRwH)rxvtP}3Z`2u5iB#8Q zRM)%U3`v)tRi~&DC;+@115^s(pZu8$j8~MR49O-E^BzjstF!a4;RvJtTS_g$ql#2LbM57K|@DMV|jOV#V0UD;04VGIcZs|J_vL zCGW_&MJbqa@&d=$)8kdP2<7EMh3#z*8^z4*?{nvY=UoS(4{N#0$b_f&nK`G1LTLDK zv-&khq!U*1nfFWmy+0u->N24DWxW?TL!d6`Cf-`Z5)-VkbWn+uFBp7~J!x-OfI*wA znfvWr1rQj=E;1^FQP>DRY}SBDTP_a5dX*rHVs1}1WE5C;#fWdo8D6)mA!mlVyxuVY zaNNP!2Ks;5jBMatsD`F7DQCDCaCaUK=D-@>X!sxGv{gLB^q+S$#hE1LEPEF<>Zpd> zHgns4LxpV6n?M{@oNtZEUJXy9O`nt|QVena3t0O<2?23(fC=$%QR==pd{=uICt7X9 zvOpnEy>Gt#kNYQ)&5fu$Ts9Q0_933b62Xi&PzvgI2^|=C6qGw}_N;U-Px=q2y5kW2 z`JDXL&cL_cwHdg?ZQsap7yJ!*uYs2Hc8quw)w|v?`p3d%?$1M#*Rz^)Ezr=x5bi)R zRnk|YF02$9HU;*k&OTurXT^xGl%&Ik#JHwv#vzLrM+q4lPEc!CgY$I;ahX^Fp4bn{ z6^WF#*dhr63Da&)@}_mwmsN2lP0=RB(Pp?H_a3GSfS|3Tk+o(@YeT1e_iaQZ1aA`} zjloyP-lAKJcN4yny|roNV>$wtNF0No7a0UOz-{$YlU3%vKDVmD(zz* z$r2wcfLN~bE+STwq)KMDJb^0AAhb8Bm@5Rnl~St&0}o~!t^Q4&;R8f!aMYG0+vYui z@Q^>HKwsW94!;hjjlTxq(Kx&xk2sz)WoJ*U{fI-R-HY zy#J=(vC}Ct4KPouSu8}&l2_A0%^q#Om|-_P+@eOv(qq4>G=T4M=B0?iQ3l_~hVDK3 zV`P@neY&2Z?&9>H*ok^#Ic)r<;kcBI!ED*F+gd5&kQe;Wm9FwPQcY+lD1g)6sG+Hq z&Uww+)2ZSVWdTVdkQsj4{Xo~^Y)y;|(#@DhT#l9MC+Dyv=tx6N17&tAX2rtfP)`Dp zR#ov!sq~KuqWi)#U2TS>uVk`MP6dY6k6*hJ`r(YMYD;~Q<;AvEJc);hx;@E@r0UEE zp}#ZuH58JOfEhs`BND;fKeOj4Ftl_2I!{HcKSNMxpV`;Sf?{d*cl8CMD3=$ndQyj5 z96JbQ&YRo1y}Z$eNnM#rW>TI2w?WcoYol8~vpC>2IsP&evLy;R^Y=Dvi!$hlF4^nE z%%PQ#9*p_E?ax@%!gFR|^+H&BZK3HA~Zj_DlI@ zTl(tF)ea4JG&E}B@_}V@o5V9T$j_(H@OjwoqG@@)b^T8Z0CaY`Z@bJ@$!9h`Wi9SX zfWmrJ(5te~@1?-2+a-x)>`SxCD$a90`;Jif_mh8>6y;lOH}$61{Sn^U#c=WZ<;hPD z$9i{ltfkvI*jaN8M7p8Vh7W zo2>F3E@s+RuKHJ^dDO9kfRPYr^4#t!-41C)wb;RIwT-Kz{td{<*`j$I{%KH_R5AnF zQR(K_IDNNpj_Nx$1xFh39;>A#bLbTyEU;5P+8(#{8Xk#k3j(TY@*r*={JUYV zVf%%;nsJCJI;Ggpn2SiZ&8qUC6yBnjJ=2?m7H-!GX8MYRqMz46SkZ1JG?g+|B0Vt$9%=`>y^1JPBa(W}w=( z1!58+T5^}@!oZ$O$4j1Xz1B2d#O>v($)S1@orD)U5{V9d$D8Awqj@tq=YksBeAjS3K{Zn#h3?(TRO zAtdFUOlIs-p6<`L)F|6A@F@nr{)CVcDuM@8NCCEwOr!CFH@o}2yelYdH}^DZgr$ID z;=NGL^lZY<0-mAC-1j%4H>^Ih8uBl(#qLKgT!kt!!F*VJijN5+IEFW3R`t4Z`%LGT zp#wz0n>@l?<%blZZuQf8WuAPk4c%C%CB zz0qTqYZdRm4~UCY1awE}W#(Wh;o-qLurHNrb;ybtU!mYM%<#E0>R=&N8#uB;u68U)9nzN7fx9g;s;NMZ@RC@wB7l$x< z{LwiO%>|KN0Pm2!%|IPDSxR>L z?uk212*yNwTfnF8Ci`gI^upC)f_PifWDdX^m5vyHKLhYx#9wa|XbV@Gwf~+uIC!KI zc^0YpzCr>%iA!9;*je-KiW^ckG3O{`^ME(c%f6?Ib0}4n@{WK6OQN>++iX&l3=7lD z++bN3kl-kq4&)rc*W?*z8ln+fOsJeG!k?q5KlE1}2d0cG_^HR>Fw3JSSWm-4K3qIa2xW&dF5r%4$R^n9+>i@&%KM8|ZP|b1)^Yk^5 zxw@zT(L8#BB5<$LJkYTb!|n%a`4CVLhv5eXnEOdIhU+A`FYH#=tdR)(-$}UGCNg)m zS(!K4kqB1N3`+GJ*y&VBrCn6oP{cPw*p zk>w`INuj&VjNmu*2G7GVi{<*x4sUVym`}SPWoD=45=HUu!u%*k)sf1 zN9&#MWEfE858i#WY$elYcl0#2wY}N6KrfX`Q3cG9eKY@5eDp^r*76TV-K%xS4wk-X zI|}>X-zlTm2iDRH26^YWhw}U>=+lZCRMKtPA^CLl|Mpbqgkiu?>sP>5=w|0^;KSQ& z6Cm^#S{Dw5_!5Py6_77_5GDnNLBjVipH>F#%Sc!?>K8aeiLy^L#l+I?+hv#8=~uV6?H!j3axWauepeX0TTZ}BuO~iG zV)4@waK zeUqf)Z`jC4r<8}-Vn6RGf83J=4 zzxt{9#3kQdp7Q{CNGfG6io;yT;}v@yrIz7bdzkG|V?%Z7v$=2DA12y^>xqF#Pf*Lu zjt^2?A`%5S@QjdTo3#-(YYU45Em^@CO$Q&iE+=Nji;(wBHFV=O4D!xjB!RWYFzYX%rtFYi+?nfuC8vQ=5WlT_GZ22f$@&LtsT;`a9ZAj7qGo{H0)GwznQD& zxsl!s{j{=^%!LG+xNP|en<^7@JdS%W2rDl(VVJmbV{`au68~>6eLZzHCiU$Zyw8ZZ z6GA8EYgk(NH<3iowlzB^0=G!OXdRs|{QkCwz=UDN&h0QzcQtCP<27H4QyWum%Tw_G zltLwl`DO1*v(4H$n4yrjx%T4%og(JaZ%=iH8oMJY`EHjRqQ!ePm-+d*+UkgcDfc76 z@Y8~Wq5?}8w;LwKl@Z)K`=m2}Hsm-Rj!ykp>diN35F3#hM97%ChEjJpJMNZxAPz6x zR5va4<5+7WM#GdqcuwJuHz?1JzRYx)QV?^`K+NDGoweXYkE2&a|WNx;9ja&i(xD|@)zI8 z#M|&YtQ8K8b-HLzXmR&hyKh`oN)P*X(vEZZ;Y8 z5xAqsFHr`-d-4yiRUDh2ji~KE^Ud~HeVZ8cHD{YrVGDOD0q&C=7J7j4fabC6=6D-# zmqP6GEef=>>^3yms(Ah|)dmQ9|3iMCD#iz?9WDx2@d)#{aasue*0I`l?prm!hQxp- zntK|5K#KBr^vRsN*Z_aDybY8*hMs%hwxJ1{aIyc1Z0|{X?WutlkXZWIs(msr&;?{| z=gMm_6RZ)W5v(nLPMpU{Q)X)R3R+#7$rAzjif!#H*}@Cj@9sopke3rxqtxkLAz9g& zCo+&pKV%&i_SvVE^y_EF9&TYG*} ze?C%Lyw;Z}AYPN3bUn$v=b7`HduZ`TC-f);ZlkU($R#OWXY!InSfbH{?V|BRtn1A1 z5~T9sL9?2}5_p&fC?$0hu5kf8MYMhJp9~vcL=s3|uyD!qHd^#-v{UwD$0ZOmV4=U9 zi$~pq)|CCKi0%5I!VWxW_0E*&k4k>g9>uVWS4ianBSOt+Ty!wgO4rou2=%8gT0_1^ zlJaN>rPk}eY7L~l`8!U&n`i(Vm2nu8-a7*xJ*OjKepK() ztpZ?mN!s2sCCB>1h^3|okWk~vLsUW7|0TG9w}_Fr2{R@+5l5d3XT}`LEBfFAL*t2z z`G|cE=_wU1u@s8ow=*%TYHU`b5sV6sId_}@dK_Q66^1GM1-mY42AZ@!lS@2KjhE$R z7W(Q^(*3^I+1l!AB8)u4P*+ZawX!xp#Z&e1<}tNbyv6}8UbQPQr|}j)Gt|b043kNv zls4NdXN%m#$tBxuD2*I?(WuX@ohz+5$ZvjTvG3CKnamUos~_8$R5K-; zL8@)EkwN-|C}I8Z5|V6WaX)N|Mc7nzme2D{9q5*T_$##5(Q0a{J8)LPwzk%-xYs|l z1K>YH+CZORGEq=nf_mlDnGE{ljyhKN1nr$DCLv#=cLJGGrcXUW7d{J!Tq1VlwdvgQu#!NlyMlPVjd3CCLs_5}E}hATk_ z32p%xtwh(t15KF#jFQfa`Oz?NGSCU!@}w7hQ5ir28V2qfB#eweEZ>6HvUK6}Y# zDA?L!P~CxVC<%VQY^qu&(jpi{c<)(9& zTU(-B4x*!(N*^C*p66|2(H;yZ1wYj#SJxkY2T3#9j(VN<{0i%FJTh9Je;|-c!QSgk zk?oN51 z9oFBi$x5!yUj=K*wgk)Y$IvP8)p;{tvSniXc48Lp9|KPRh8>i5rRk3C+Pvq;{3t{3 zU58A3a5o7%3JH~D0?Fj$yOfFQz)s+cYC~0`vMEijU^@)if2}VKlTz&H848;|<)hKgsB5kJZuMqHn7AU>+v z5n~5A5qAIX`<}f8KsUOyXo4S7Qkx!YyJhT|5AJ)xB*g&7i{YSia#s#m&b{DtSvn(* zPxV<`_y8yp=zJD=q`a&x3KCCNhsWq4)mQvJ&tas9)B-FF>A%HNbGa%$H4o-XFR$~; zU{)GGLX*$Bv+2peuZSIW3>702Tj^Tg>oU|Rhzh_5IXG@AWR&1iN4aEVlyx$}Q%{q% zSJsd)aY|Bkx}_k(fwv}8{fy^1%>wKA12spjt5(Dx1zfd=pEyNutj{l_Ng8o+_d`vZ zA|oXsY3dJcg6QV#Vn5k4GYuS=+mLJ>JiTWGn=kk4Rg=LN(Wdq?YFOh>$6DV)oqrXZ z#{8*Vz$j1r@!p*!F(K|n`G^RRw|_&kwJ9u*VX<=9YtyZlMpPj)I#E>{lwD4M2<%z$ z*Zq*!yn4=?5!;=6`?@oJDAAv2|6Lv6Kg6`+MBHY{8ukCP0VGAEcdowGpY*g zx`eQgPk-XM7;HmPy`jkz-q|4FeV!egK>X%Hu>D=fF_}JOAb2)Of&^{n^U|YvvKf={ zp?TU$*njQ2XF|hGq?sE(_j4HcLhr(z)%O#jWuPjr@+~b$S(-_Oi^&UlN#JyhbW^=X zZ{%HGrRq|lL2?q)9a}JjmA?mm`ogbj#w|j=kx(%?HSQC?bmsvq+#7?x_ay_J@dcs@ z_-aBDLy^ty02ZABdpbP!_S3((c32 zZ>u{;+0d+>n#(``F`sa+Ca957ZgcuwkGj@`t`B*3`SfOE?(#JgnyTad8S*7fNv;c@eB0(syDS<-PO9kRUw z9W5ZDjxa))Q z;MVM&Kn;zQIKVkddXj>hYM+DV1DA6FA+KT9fh+S)q?z&Hc>KS+$6^i4fctej2Mv(w z&t>Ik)?k@v84-wr*lTb*Ke)ICs{zt-T9NC;K)_~gP_I8fVXcjv<3#G{mr4zwKRKm0 z^Ocz=^9)=8j^thsb8Yod{TJcuvIFc!P^>^;1XZ>$IZdaflxKREWg7HA-ocU^^{H^}iQxX@=4ks? zfL+gu75~bR!6w|@i?1pyU7P{AOSMp9ZuVswoz zr5W7~f`BMFxz&>snGv18l8Mxz!V9+4kjsBSXt_6ZVHPJ!`i{>?(7; zTY4I&H!i8MAu-bHR_m5C7c(APxS-5$rtx#UW;Av^Ia782$mQIzK-V75lAl!c1o%=&?(x$cJsrPST{r!< zZS4CEk9Qdk1eN`KlvPCFQk-k=>MIJ*VqgKu?wppcAD@PyW$*Lj2|} zCziCj05EcB&aBjVwliwT&y0zw%PC?Pcr+czJKs@yI;VVl&8TD12#;-NUr3^5Mq z^ldkl|MAifp4YepP>+t+xEgGHVtl!exs^VSC78&>-(@iYtmL;vNAORB-?oHiXeF3x zRL$!9`*q4@F1I7~reVJg>k|Q5%3e#Y7EoBBsocxv#Zhn0_@JwiXqcX&p#+G?& ztb-wHV|wYh<_a*9+T1O#)Bj*XI_O+lB+}or0j!1oOdrsMfr~S`G@*OGesY*4A&Y$I zB1)=p&*cu)9foUhn=h>e0QU+kOT*i@db6k0-GHwob+rR(@{49xyY_zo6Mk2`vJXJ> zhEqteq9s9K%(kkWpiqMe^)Aql)OppBps7Li^)(G-dqPB>Z0Ehz~NAavukpROBAXc)I0`ERPWM`W8eloy4aAbZw{_-L(eF%^C` zek>gmGh>0h&LUD|p>}FIq~t%yo`~D^6d~YtJvYpB6hM{#>u-zB-8G z?hyr86$fc)Gksi9LEQ%IciEW=2e5i-@5g$bIm#}BQX)9m4EjQbf#ZYO#I;Ei6{a}? z8|&#ag4i7Z(4amrs~6lBr;(m$Bk*6mc~Cah^{O$k(Lbkkh%*+yx%EkENA6p^>Gz}w z6BF>}7Z*QEhvx0TuGcdef27{ETma}gn!$1WEovNy%18wV*}V6eqEZnHgdReMXQwu5 zcib<%pX>vr<9eLnL{pX3xvCr0x7OCYtfMM4u2l;#xuJW^|dj;!UbS00v83#9J9G67<<{6pRQ znx4M;Y`M?JX?u--rM+Z8;+Y-^1aB zOOBg^uU7@{z4k9m6|EaCTzSWGa>@5~9fzDyYvEak(&mD znkBYa46!(@m8Yj?A$d{yr(3{M0E?FH;opMmPzym{U3BAvfvq27cCBS$N%$yr)#wNB zMbaJdP&bPI%4BnICu}C*j~&6N!r1sZ=hWC$ONn19kK?wD6F2|pf4BfnhwSh}>06g; z8#&hijPLom1Wq!|^FibwT_UWSFJS+uG}q}Kft|rtfkH$ga<6*DkdjQ1ucdyuH+dLcqXkvw5ykc!JgB_ zt0Ov-gv}iJ3;Dq|B~KUe#uKYIzjKP-(ffVax?0M1I#DzG+S+LPEyi2F0+yYS5YDpc zgfD;1{DxA$6t6>a?_$psdDu-g&fk?$3NlztZ=y08PA035e|3T3fRewjA5`ZU<&V7A)dagce#n`OYm;_DOuLIp^nqA&+=Gq!#k(0iByB!g%bE5 z)>}X7o>-X>xKOlh@SrdX1Q%o^_NHP~9Xq~6{!fqc60A7OnyOE&nmelf0n^(9^(ELz zE?sNVvLA}9_zQRgOB@UwGYYi9O0T#_$u&Rg*PF|ji==Xh$c`yEIUOZ%{l{-%3f8w< zdh6`VDjm*+llkmk0vwV-qsi${e-a4p=sgm;zx-GUC={y&_NmoQHE`q~f`9wR$s4r{ z)Z^7Q4D+x8`wyfWFLopzx^Ul0xmW*Zw(}9T>-92o`#V?D-M*=^W%j!!qee%+2j@L4 zrI+9K4?Y0%W>(uCaB8(qRP;-~Nqnofj_k0iqney|~f_1=v$iBpjwB>=FL!LtUJ5z{@$ zBMWq?%yOKWL1%=8I&uc9mHS}t695Lh!KCigi0hR7DY;8LMrdtA@|ylNtyvP_Ts3%T zW#9+b-Y|Fz&@&j7tElsVV-#@oLIp$4d=55*8jEWCE{{$fHhLRhU=CB@im?=L)tGPI zRQ>$%?xC^w^Nq0CkPDr_ncUor+1#bq0xL^B{Yo%jU)nDQqo~sI=y!Z*xNt8#6O=eN zv3qaTtcjMuB|DuU>U#EHFrXXF6RISiWKm8=llmyJm#*+wc zPM?zdIKI`@NeO8QtFKD?_H1fsuHgW~xBBkPbsXB!(v5XBRCZ92nv?qa2D%?W zPXQ$&Gh&LP`zBBG(?4AOY&+}Y-0?j1uEj^{%fXaFGy*Z0!P{E7f-vAXPZg^g7E#}n z_fe?xil$rkqar_979YCt*kXUIg1C0AjaaKD9}HEsD6Hk(Ww|N4IFS>a`O0bh#+EEzfr;68=DOvcDI}Rc@RIGRh9VVnzZx`UUmtU-EkqnUhntvv zBSd6MNdx%xCqkazuCg#!44@~#BAc#&H!X*>G%l{_M?^eF?=oDOG!%XGS|%;0I` z=aw!_I`D0?J^7;3x^n03Jz;sBjME_>|rX8+tq-BETpHlJV7(t5c zLnqbi%3hzw3E!zy%ni-Mcy}{<^yO)6cTw)3ILTCP6IBYci})ouH@y8M(?^^ZHl)@u z%RG`oD>Nhua02B?wWv55=mc+-^d!G&>5kt8fxz14?fry0J%F>f>B{(iiTGD*EzdEZ z*2A+`l?g6p1weu;#>&*G;c@=?o|oZZ=7>8nJ!=Y9^bH3tBt;$dlilgU<#x+)?!lAo3Ev_dUr(x>&hWYNe94Zk=z4V0tbz z;i(yjs^OlCF^q!!;Ws*rOY{jY4#!9){W%=7Sat(iR zV7?Mm#LUEpy9_w@eG(cg_4P2$?;4IT9iytPNs(!X0P2aoFz{u7G5Q8jh!E9{Y-57U z?Mp2pX2Jt{zxozV(iVFnFr#{&jQ9q{-c4)HDwH&cu;+O{>t=#J8N~6emxr`V!*kiU zP{KkLn+ie0HT$`ZgCQrU6M+iqv#J$R8c}I|e#nm-5tEswhn1^4NxVx3a;J2lKNcty z*(AJ)zA?81_RVU6fgKf8+n6W(Fs6jJ4yGttW1DDs-MSwl6QEV^*^o$*`5fhNOb>dg z8lc5JT)FD#@osUfAu+v>Nm2UNH5;fVg78lbx}s zP}sl?IdBSWcpemRc0`PW6BsZ4tMwto!vm2zj>+B+CHT1R&^D(Wv-pB@g@K$=cV|N* z>8~pgs|@;ZM7kj|i0S7lOJlrvlj8-oc06Pr_mZUy+>G2)?GXkR3ib}<<9Ntdb9&w7 z@^h^iNrn%<)f6&u6ool=bt#V769yH&I&9wJ)`Lr8Z=V`-+H&814m##fCBpIjz<#sk z!;_i&FMH06UR^Ck;5Fw++-4~R^%Ua#buMEMGx$0GuU-b%?@G`vI%Y06IB5WHLXr+9 z{L2|GN(Lf)pHB8ce?iDT>)FMzgj0a9WVND8{LNhn{&*K;R<5F20p`?$|&T@Qv~=mvH(#gTCwNqurmEDdMt-;K&SPw$`oO( zF&1H0L<3VpRY24smyN~FfuIWal9iT+R{J0eWw+RDJRF>ku+MOJ?X7kip18dv)7f(a z>nO+LlHoxJh38YG$(Q!hWD`x`&v0;NVxW6*9JepS>%tul`8O{3Ob(tvH)6^g!i&q) z;jMsj181GOhz?>PXu3(VMbkNq$!6^p^z+`W(F>4diXveV39)(%p<27fka~9nLS;iVy zV6$mG`**I^$w-V71$;aE<{qb3V$56b?pSH)i#az?K*qQ~qkU+sW_ry75CzlopLvU` zPNK9wn}4Lbcdjv!FaC*pv;>V6Ma3&MaoRyFg~Jf%7Xn@a6|#)<#?VI3w(Gk5?CxSS zkYz$$)OeLHUopfzuaf&HqcNgd4}T@Z?jvJXrbyI9Y3hL?7xHV=Cu_GcOL7KDy$eHi z^nPU$pP7fu9!fB-jslMq{Gm4ywl2`Cw&JLrcdvqZa^~UD#H%1)SE=08~# zmTsEaeQJ#v$m~xQkQaY7L3^|noUxylf6yg7tF`>wt+%3GRJ+jxY}q@bTsK~B!uY@i zD>UEf-^0`wKScmOP16rO7W3##sDJo#5cLX4v{?+|rFmLrZrb`aoHv){pp*Q}rpB3i zixrUvajWyYcJJ8JzG>61tGPswEgJ5j9)seNM(3dv3+|LSI1;sn-Um7faNRNoVM(qx2xg;yWF1(y$W;9ox0kQ4d7&RkUel!>3QMCHp;Ys9&b4|s|>8fu_GeONM1YPw#r~- zx%Ne;AMC60&YWC3NkTJ2{->!^ec>?6*bmNib$H8_4Kc&7=~5O9Y_<}4Aev4`*iv}h z)WWjA_WO?=kWS@pl)Z0&?bVyW(1+%+R?mLl!oj)CXlPYDK`E5m^x3pgb!?Jq1xYMd z`_cMx(`hf8TU2p<>78&{j!HkvtYz<=C+gNig;!2aQYI<0u!aT~$MYFb*aWVU%tCb zLmG~f9MV^5|HVFeDQtD-i<`{L?Yy+kXX6Q+8C<$PU=#JETiQ2@&Wrv0EoRQ9FP8uc zcqA6Nm%ZK6lfPKjiw-aC%8&?g%co%L!<$;*D3!8oC2ctb6 zT#_p*QEl&$a_)z8a$Vs%*RQ4yyfnW_X2VSeY{5~-`6CcTOT#MtpyXHq0>jr5yBrb^ z595aLVmtFCW3H#9vXR`U(9V^PgN+xoWD0fa$@{+o!YIv8y*@_u$J*2oonK++y0w>o z&V?f20y)su-kXy~8 zL5r11DMvz&<2f*Gw4RNEMd|vIlD4+boBV_KK{TX@(Wq_!l(6&Y1nW*{)V>CscS4jJ z;Xe{<9%yOs!oj&gi@H@q^Q>FV`l5Lc?|;kdhcPE>RCmuR`iW5j0a-E!UZN((-3~@$FNEZ>@AfO zhh{3I3aLF2@>XrqUtDmKJ6WREu4pPd?e^BR4f2jX@&Ic&6ztO2dsIv5EBLV3ucONt zmex9ru@bAvRG6#*lUhPL_g_-oi&cx^F|V~6Uj8*JfZ7;x^iXn(RemaZMuhW*B0(pP z-nu{caw%AN=h+48%G@@6qAKsttt^Tdv!FJMa2SFzHXkFY_xeg^xB<1C)jSy&NfrCs z2d|dL&F33f8%2>OukE`>#;?rqudv=E;}QiqeTnL(>D#}`zHyAmp2_3j;7hA(eDCCZ z#2SzVjq8fJgyrnlzUsdJz9@w!?>&!r=g5L=P|>95WW17($8U+24D(|3RTzSAcn5@7Zs;ELNjZn7TIuY4b5vJd?j>vT&Y?_F==k9GHyLip$}+QeyX)fKBQ6?GB@ zTwgOF1B_7|$w0;u?(4J;Tn;3Y(xkarZ_4n3bKfx1SD^AM^oGh&%=3NTKs4Ye*LgUe zex`nY=60HG!sC)@xW2m`VEBA>!F{*qyj}iFh2~?6IEN2_UH*53CD+xgRqKRD%6$p~ z3>*7Akmp@84v=*m*e|>hf6EyopQ~EBz44Hih>*0fM)N6GRLwGvJ)ta(n@Ek&!kzDe ze0=$h4W{1*-)R?WR`Mf7&Ie&1bz>Uk$Gi&YmUj$cdwEgq2d7TnJE|#-2UGGjnVHHb z&O7-hiZebRQxA+GUX>!LCtqu$sY;zjlcl|W#K}dqVni`bVpY~=UHg<&_b6ama)}?b zof10{7d%52#2Y0_qNUG`4 zDNf*=+UeMbpM9R-$YMcm`_xrF%Q!fgiq-JglavN|aXJ;I?rK6a%U;Bpk0y`-erVFo zG45i&1Ov@{w~E;B^g#qvKJQcVa8;Uh0Ez+M}!hk%SvRz{;rkschU6NYvBNne@_ zp+*ExB(+;qV5()=W_1)d@*UN-a!qa|fi0}Msu0uqKsIFnvh|Qy?|YCqoJ#1xA?GQ* ziA`#=& z{QtgioYCjY{2OpeC{hb&RLfq?+YL zwNI|CZOLufZquh2GpNRCa^)gR%}Us9rX?t^j%E{bKAO|VRA@X znx|eYLKDtO(jSsoj}2t5(x3s1#$D=VAtz*zZY&GGQp@y4xLK{0S{^!jF;6F) z0H>Hyfw6)9NJipZp9YiSZ$880BOCcc{?P0A!8{3huBI_eJ=EE(J`w!z)DxKdsFA(y z-SJqZCOcij0x!a-PZS}cjcWFXq`%R(Up>#d|3BjK{bXM@O+90DU{qv!_4Mi36-Y6o z`0;#a{!=VFselW)#`CpYP9laR%lgi*^Y)#!{>kI|R8x*igS+$|{NX)$jEJ?BmJtaT z5TB&wO`Ut=G}_@8)kFR8aQJ2n9hjV9Z-^)-ulD6=G3saou$6q%6?zbl1;U43q;Pia zc*InguJE%+)JbXTwjn37YX=YGc+5ghCpn=9+%&L`53SUhj3-{B9?Q$rYLN11kY=~= zOH{qk;!gl3=@0){$4sku8YXv&NF@aP@?by))-RtH^fA(x@U}Ly&iYaOOGFx31`xUQZ?SOLztS*ldFh)2fj!)(a+9|ro*6@QEfKV~;CottO#aBEK)o#y+XxhGM` zyr9<3TS_Nf@73ho$g~GsDVa}I$%7Q}+OF>(SX4=i>yExHEBe|7mL0u#xXRg4^EqCt_?N;9FJ;IJD#sdk|BF85^0R^*|iiS$qq&dE)eetw9irG z%JaDQnVN^A?f|GDPGGVUe#o|(g*g{ z8njnJM80fcqLJXffN++BKLL;2Sd40I)#d$b1_N66##5C}=U3dbyv%$=la#>UXN(rY z;a*6JWGg6^N43q0x^G%;Q^8<*sVRbBWqppEQ!DJBZh1um<;^al?PRhURIK z02Kc}@mm-hzpq=!WPOV<NV#uSt6iF8l^y_eukAkOO zMJ3yTrvCE67Os~lbwkzmBztl;|8Rijx+vvvMb;j2(&o!;Th4igW=6%_=M|8U;bl7Q zLcS5&L^4aY1g&TrUH1cUu) zKmU-FF0^-_QnUQ@xG7-_y87(u?G9e7j51&Cey8nFAkZ_HR;|mIR!*P8XIR~Jd$u(H z7zk0Y5s8>gQLFJByu4I9!Nq007%fPF7s+Dnp5%Uf+Eb8#v1f3jt=6T;+*E2I>q!oQ zv-Ao{`pJhG8G%=>ya)XH%Ww5_*>f)0?AM?4$A9Nyh%u?SG(e0;B4_4T5eZ+v+IFP? z*r!HP8zfwwI2E4cn3ipht+1YQS`*^jXaeja$?ueF1ywvA1-M_Q&b3ean`&xw|)sn2%p<8tO#tfIgY7?05i zuu{Z;Iho8y>eIF_#J)23s@l=Bw$?=bgO{IeV(8>px=}8Z+~UFCH$C8~c@UK8hP#rv z3;q2S{d=et#8jSTC^N`?!gNI?&sdt7i*n|CS8}<_Z5XRD6*=!3=3Q2Y;Z)RLqrd3} zE~FEl`&uA!$*e*r?g$l(9{4f&nUHH-WyP+5#YQW6IGW~b)df1g!5qPbLfPa=mPX3& z0l?hDdSz`d=tJ-`(_%rOxcbOOw9%1!{y{fNXhCEXR=8|T?<(1I1!i2$9IGTh-cX;P zMsKIL-Lku?IYVw^z3t7?tryHukC+H|p!mV>658DE^zfiG9O#Yh6OyP29ab)yB7<>1 z{U8gW=r3ARuO6JXv{)Hf5ndOzAhW8{GgW9u&f4k?7_rLuNI+HNZ#$kJtEhtGsX`D* z>#Qj!9n{*0)D@EaqmMHLT1KR%#z-+|^Ky~olUglsa*6ZAp8fgxYP&I3V`7|jy=MWS zFpCrEe*Vq-{&hldE=j0TgZ$u-K>xDP&zIid zv1Zy;PbKY=XD&i zEs$ONv%h_FRcf`h%0|h{!^L)hz<+Y3Ybv&&$6e~1G>`~CLT4yZLE1yv;4b|sLNm_D zEX;nYHlO)!V_(O<`X#mCL)rA5;XIf@n*7bw;YWT!jfZC3Y zzFhq#dj)JnavNgewD1zlacn0*=1Z@(ETdP!J9k~2ZveUeFJosQZEjn9WVo&reOpx! z_R^kp)O_GPeZljddyo|B2kT+}JQj+wrLlPuy*} z-tNPa;k#XFF7kx@-?E^ZhGfAh!c0B*b(UI@N;-wz_V+fhM_4LR_~C{%i~0_qhrRr=MtP|V%#St5R;>j+4zH4g!ON} zx5~&&BQ_@B1+jgO1zr;# zo^pGBr-D50+z*uf#o3fg|Lt;aud z%G^T(95k&YkEc&}TnQf+LVOo8Ntq_3U%-0Vk%P6t3UHNp5N?OY6d zt4BaWo7e7KDGj93uO6LH~J3$MET^&UT#`MiCXe)$62T*lT=87S18-E19Ro_ zCo9$(nt6H#b!A3yuIBE3@`ORSS&^qP-Wq6-vFFckdD-LI; zj8a-%9c>)`S|MYox(P;a{#jAwoR|hyzO+DNy_Vw$60RkQb{Y%xDmmb{(W=?`v1u-w zP-VWiYG?&0)qjU=w41?vP3p6xacorpjOH&_{`cwe`thx-LW#E=ayv@)^zK5xdP$SG z5i%V^f68WvFPfH*+d|OyPE`to-OD<2me6}S^dRxhHH<9O*=6H05a2j$H<SrN(b4+QC2e{mN=I*eHh96ABa@M{qf?fmf(T*) z{sc5DfAelOhLx*XS}iLvSEw~GR*P2BG=LcMniPEA5+KgKi})ShHBF2Sd7$?cNY^6h z$d`SK6g)vO$YiWwFTj`FK|1~N;}3J#uxc%K0YF^X#+C>}>aOVUhP_Uov# zDgG2{p#noI|F#jm7@&&x(ZJyd(RqA#rX1)_0{)yoo+gH4({Zz+ zRsCUmehNwr>U*&<*fP>A#i-v5X0@uMt%54L>XPmHggqf|FmOLDM-IA`@QPis&c6G1 z9q`Fs(+xmGvpL8S^X`8&DD1{ndN-mXC_o7Yt40t)Z*hSWAwM#=P1NWFS{};)jH%0 z3}MRjMb|absb5?fXu% z`XO2d`A(^rjG9U%@Wc;X;&mJ{oTYD_=sYq@D~a=keK|lhdg>{!PriiNY)Ka$n#0t2 zumGGv$EzA3HZLhTfU2xU9zfDm7@}b>sqD17z$yq$H|v7l8!%`72M+&IneUUbdXcMC zr7tT^Xm^7y*l%C;vIYpQqV+(1A^+gu3ilz97^xSUxylZ-NgiP zQT=RUvVlle0AjB>m#Dczjjarcw;>+?l7am%W31NQGn0e||0Hp*Sdy)l#J%UAN(c9tb=F>~Stw|n}|b=~buxkCT7w7gj%TOi|8WwzF zwgM5DI;Miyr0C{_vrpby+rD7-# zY(UVSn;d7ZEuiw*vN(sjU(b~JT%H&<0MWC2pZu@vvT+STEg~{P#c3bp&w2n934HBu#q= zld|~R=N5UdBbj_;{&&q2b}y>^uye(~|I>ey`*#2r;kKj+8yAXmv|zKC9qTymB&yeX zMklpZ7ig45kINNpv_tH<4nr0J9OGiI=V?0ac02JYr^Pq_o={)A6B*ZR>m5EB=mPTo z3XoIo9ghI?887cYya8UGuqq9Z}yJE_|lJS zm$n#dd<(^Z6)*tt<%9q1q@T|NOG`U&>|A_L`Y;z$Vy<6+a${JoU!EU?m~4J2y!$c2 z?aAGL*~^~A>!8<83+{g(g;8LgC#Cl555~`%sx#<@6AE5w2Fzv9$wlOk&lL+1j4;#O zWhwP;%e)7sVECBiB4!wbpO1dv)C=`;dd_?^#@hGTv2sx*tqF z4?)fX`Vz(R%)|x0k5+3bWDQVC59_EYVb*k8vc50OUZ`O!$(OH@X~FB+Q5R zhYr9l;$QtVkWv4xc#9`j`H|OGAT^`$TE@o)7_e4Z-))*zC5TW25^wCV9lZ#Y(qRR4 zXDynireJ`7bY8ZWI~n+|+{~bwvhkU`1eU!7_~bckJ|9TtGQ>mEftj;r&gTtSKInVL zm+wrPZ^Xn!+SMb(xGx2D>s3wo9>#dJXgJJ52r$>lR zL!Nxt_x|>YEgk<2^f(I$%WWW~zj(pSB@OL%)IBk*9aHGn=qAF&cpkIF%!L*VxdXY= z=q4X5i;Jt7F;2GVBlUo%hn|hBQ08&)BG0db+}~(>;fFYLI>G2Y3E_C9%jF>_Y;@Ta zDPqV#rj0bZ7Xz%IY=Pof!P7~ANj2b5Vm#4WW7XQI;UNnYr}5C9No{V;=j>vJ$3m3zMwnpIAoHq1q47L>uberix*lOi;A$(=GBq>o~FEq#* z1O=>r@^+ER_qBjq=g;fkHIcA~>YVmwadGp0#GAE^bw<@){8SP=keuxA9}erpb+X8| z8f5&Xl4#R!o?V&Sb;y|Rn0DcS=NoBYH^WX03% zFk{bX0?_g@nCiPlqns}AH(STcbKQ)qH#QMB!rW&GuDE-{#7rOgFFzVV)aoWC)jpke zRS7q5Ve``4rY1#3Z$H&2M02YsS0vy%C`e7Dkl4l>Q8|2*z55!Xoc9&Fog#td{#daJ zxwpv$(DQje&G|P zE?HQ}jL_BQEItqWEf}63T%f(Q*bhm)pR)Deel(yG92zzEfY$1_AEGsnuo~j~;R_bN zZ(G1@$u`3Ep5qTg!(H6$eDqsc1b#1{QO!$=OfjF?iK$3f+1a`PcCh?n*~L6cA(F1a z#aph)FQV~u$y(Tj#3{r|Ih2PfZ*STjj$`n^ zpd<(g_x@aC9}rJ_}Zs+xM>QL@sY zEXX6l$`o3QO%y2+eLvf|ylK1*#=O^8zZ?o|o>7rUI;ngF6@>mX^{XGYbhY*X2Ms$U ziLe5AT%BZH{VaXHyRNBNAM)YUn_WJkEz!JS96}e92&7n<3H$UiZzj6kX3B%7tCIegAXcW~;pJ7t_a3=mQw6r6xW4#^m`4B6%`_VXsrp8+PB-|sc%uN1<`_V6 z83a{IRA}W4!hq_X@Z|pJH5}R@zbUFv6U2al%7rlb=ms1f5A_%H8)ew1VoyT2r2@@?Vjn`KjS2 z(scz_Akg68e1C$?IzN0IOdPEIX(#a=J)aE`>geug<}@#0j5k`5$F>%%a#+NH?)#4{!}FD)AeQSuFGZR`N>Yh7%j^FxZ-6wNpE?fz@JoP zaHWKURR*k(KZxvF^T3^#h(EzS4(Fuqw^koXPiQAmy&Oo>Xac0rkexpP`+^5pycqn? zFAP=17;oF78SXq5eQ-eJwOM@pR8NP3-jjUiW2ri;e>q3&%(2C~B-SYs6dy&Ox+TWN zt8Od4HG7*5GSr=xjgtVSn(n-To+-xqc4Ca+GQh4uI*0xdM-vnGS;WqW#HLgo5DmHZ zvK6TXUbw!jqzPKv2QVnF7`i+b(RB7?-o}4I<|z6(2wPf#XHX^mFFhLsp>okZ8 zVto7wAnnM~`$twh*s3|ft36Rj0tB%i)3M7YH^A1r>m)FUTsoTK4=roxs*1)9;7=sP z35@-7_fzPYwgA|LgTpRYz*~0vF7@PG4EnzOuwJp6^sVDB->k6$!2o^ylFBN{^_;H10uY_nw$%{uRRiy!ze^#QMgSqGb{jt#t9}E^4A7t3vv>-XQNr zdLhQVuFPO4Os*@6Zq;xj^w2)+wVP)N6A_u6TrojqMQ%mD=~6F{3Vt01T6khbGCs1b zOfhul7)C=L*z3`p*@tMAlRnxydXTZja1|8A7@74wRsXa4lFPI>)&3i}TwK2zXre>F zX>)usi`jaDW9efgVS$Q@Rh>Lx>R^jO#R35YY#3TYLxd9;&41u$GP)3Qa%ny8vKJJr zq}SruhbRqk*FP-bgXtt8sAmvaneV(U0dP}H_PgSPQH-!fx(skt`X>w@l#2IcYKUh zl{WMY4=W)iz)MD&)jQ`m*OgMN;p4Q%bXL5sX3n^neQ~(Qzh(B*eez5RPoz=?Q~{IP z*UL*0k?&=SA>C)c3AjuB2#tv62iRGY~ zK*w={;>@O?jCv>JNk)HG)AujzbwHBg2VhQ}0GDm$4HH7!r%CTrFvCC6z5oG7jd7czMcm07~$|TxmzGCN_u+xOCe)kNq3wyOM}+GM2y= z_#B&4n;q|LY|cVrolS8OY*qD7+0)Czd4^BwtOyyP<=_SBklmhw~6eCl6yzxFRpAj{QORF#^UJ!bd{zM9e0vE$VDu!Oft`OyfhXZn1z=Gryh54d)YEtv5BS22(1t@a> z4PN!j*%$CoHng${%f9A2JwG!A6shd$DAQ-W+e_|~b`94F_mqHKQ)vic0Jf*a#){j* ziv)+ph>Np#_0Qzn!T~N$Ea;C;0y)82=K+`BkFLb76mT=qp-4 zf8;f7Jo2Qr{)|Iu-lR;{`G>Y^L4q9LF&!wY`1Q!#iuYcZJF%VnQ2EZtgv1gYlcepA z*cr|$TOS;8mx5QdkTQF1IaaOp1T#tPv0neM+emW0w=4jH16WTue$!VcHjp%PdjDsA zxVv1pMv_)!@slPui@tZ??B{`$WXON)P_k%#1`??%4Yq)DH{=E!ga9N(v#-vu-vZI9 z0t7&K-E#1@Eyn!|;$UrO@cs5{bIYrsVvK$)kDU0atedL%dZq|qEjBB<{3slJ30b=8 z+9e9koVuauQbh8&-Fu-jA0dGL{C{KXAvV28Z^k%=64Xuyeg;S(s#w-@9%LD=2>m*y z74^xj#N)MGwiZZbjwy;|bW?93^42v62q8}+_h!dLJF`$_8JL(rS=hpKWR^)u+ftZj zFQ8UbNF3@oQEs0ike=D!uIF({;*_lm-OHLfWm1*2bO1rS_bT?<^%FN^0G1SMlVdjY zR|g^F2-WVqO1he4FDyX{$^vKH=q2P`b^4_u&`N{4K9;gtf@7eOr<5UIIZU;$ePfhA zy=jLm8=!U(A03&*K=K)K-^NHYDm)Wy#KZA@#-NycWhbgjwGbHxpmE|dp4(BIz?SlY z{^S4Z5&V_Nolm;ofo&KMZh#k6#jfA!^HhG0blh`?6AO?s zCx_QUw4Im80YGndHRAuR0-2e;a0B4Jk#0FInMAK8CME%V#I-}R0!v6cwjAKjnM{)*QYttIou$BkV z&*#7y{Ivc5=?7qCFR-Yu!Gg6GvH|5IZj}-YAu{zE&Zuo8hS>vLucXa{s9flKEO$Q~ ze^#30GE*;;JXZ$9ldHpi@}4K}o%NazHrjM|R!YYpxyqN**H4F$S(%2Bj|@2jZO^QENX(*_Vs~p-*}kwq2=B<+-KGk6KG*c{eB(Nu($L;fIL zH?C3J14OK)0L5>HR){AR-8HWNCxbWmjK#U^mlZXzK{=tIeRjCVb6P=-LE)@FhsilK zKRFXOKPD(g`WJ4TiL@GRo@{C6lb#z(B)0ZZ-Q#-Rd%R(8cWRtB%6ByIoHoYCla&NT ztNAKXmu{{B2WG(5#FZ$C{8Em=oLMX;GAWFD2}(3w-S3-P7%_onVjdQTXE(y|x^Dlj z-5IfHD65Pk4r)cDRz??9n@6>LsnA)szkMHu(Be@Nbowe>$cLs)v=EFbS2XeZHV$Zh z4}Pp0>(;knk5fulZdWgZi^!D8N8oh1>HEX^%GPs$M);;Lo_$%Z7j62i7H~kqk(7V&P(1KaWGUQuQD%R{e?dMYO+-w^LEA9Kzi_CLj?NxU0)>yxZWV>1N;TZ0)A|Ws}GxSH6h^ zZ6~C@-I+qWvh20py&TXuz1-0CrHOax<@e1r*#hjR?WgrtY`X(aGtQ<;FQD0sR*Vl; z*v?*RYk#*!Gsu(W1Xt1Zer$(4-i$S};#zdLqy)tZl?p@KkTZw7+YwF+rzxA9l5qZZ z$&*Hc9x9FJ;9Iu19PRz&Q`ZfSeolM4u)GY}!cQCHb0#6S<4i^cZ6dQaTPIq?=3VeO zsv>3@cy7?f7%nz$uC?b~KQ0Kr_+mEVnm)Mv{S#@Nx64xh6lULFd9x>@nEKSVaUBEv$mdd;$oK1y+){yFvM|NIC{ zzXROn;qFlN?1Nn4qY&ap8dY8r`vT|dTsR{m?q;^GL(K=Z`;Rgjh8D!5(GNLOI+s&_ zj&95|V_G;{i<%Q`Ot`4~4Khz~lQ}rHwCu0lpltnrbiHL%Ty3*0+CYFLxCEEr65Jhv zLy!=H1rHwF-4fj0-60S(SmO;et_d!UyStyo``vx-zWcoU`NtYP=+X6*%$ilJ3i*{W zcJJ<>1LRM{NE9S_A%gBsJY{#2=ftCe+8x%IKB-uPS8TKS1**o5WFVqd zU0FW|y+8w!_gkm7CmF1zW$D_oC;ec2L#o?viZ+Ea?$gWdeW8b^RTpc(c*yYniD3o7 zAtKMP`7R?|3o4UB`;^F#zCsJy;HHuKlY&Z;PmiOlSunH8Om?MOBCrCb-B|`n`lTx> zL*<2+>v!qqe!D;EJCQil;;Ann*%HrFbZGOaX{KiLz5XtS*j|jwpm-K zE#R{Xi6^|u!7R4lobp}%^nVHRp9jW_|LoB=zsZRy=*(3l=9v*-^jEZYeBE2!^SS=r z-@pfJ5zM)|hs?y$qk<>SnH)q#@;h3UUm0N@DJt?ce6Zvnx^QjX22ttU-;2?((cPBA zN+un<7rqU?WNR?YcAvk8@Bi4YnQ6N(prz(g>FARqtl z%sY8%J}FoUufy9R10<;VX?*f$(DSwl1s_{fjI+O|#6bz(-dp|6L3|HmiNp8fYsf#} zebsBYo>{&BFwFXU+8UTR@~$rjS~ox4l+gL`ff% zK&+SglV9&cOgtjz!q0(e=91;F0)9_3f7R7o%u19)Cdm^-6jDJQmCHil&5t?A*&^NJ zzpz2=1R@ek0wa@oF4@t`UHZw(>S|ww1~bKQcqIr zA>T{rBE`*>Lp3YI)&EtHSWR5?z?d)Ns>hBZqOPg3Qn4k|8 z$X4@D-|lQv+8pWr_D1b|6sT&(F_(T6igJnEQ)tC;&A}_g%%4+CM&@#_Qmpt&$l|FwD zTJm2PjAFb7R{G;a!$V<$@zw~L5Y;^-;+M|R`6c`r zF8cCUNQgXWdVUup@8#r=i0bkljmVOodMg_ zXVI*Z{%ZRo?2yZ7DMTB=6!Aw!x=AM|(kot0q+;Hc#A8|zbm1F@{rAV?;%^n4wOnSq zu4OZg;7Cr5ii%oAqeEWWEPo_`NJPFK)~_SRnI`b|=Z%KKOAq z1&E5IgK_Nt@IWu>xrka5x_h>3eB!S*d9&d=FI+HG`at5AK*@S5zIo>(q_fXi z4ETV)nTl|plgJ5ci27vE7L24@28PE68xckkfBD+`KYlh^{OI8J=vZ%+&Wm`CXrM8-L3BZanS&; z$CO~xf{WuD)@ml9Q4&3hXn0_<3GH`qSHrdbMmF&$m$HVT-bL$$)<(W(2a&!N!JGjL zz@ITW;}egKL#F(i*#LwY<4;YCWIq|_0S$QaQavzS*sAsNNK)w9X3VtDTp#*EX7Xzu zE`5(D1-ONOJF?_~e4%}RxOMCR<<58Bo*W^bdU5<~^45)9>_2!{lljaM||DUh7$?fgMVwPRhY(R)KKUG$$nanG(FqbN*(5H)ykQ+=_= z@K|^61A1K=lHG1&I&3X^GV^}5t#ZFo>MGiT>_MRa_^dG6F@{jhd3toD`mw&rnb53; zZ)|BW`T-ISnbO?{6;G}SG>!Gkdq(}fe4#PJVrmz_rW2PM5~AZ&%iqm?iK={8L$RUn zxzk;MZvRa|W{A09W_pUV>=J5GFh5ChVLFiXwn(ACF&Pk;0_7xMue@hch0lf9?8w^U zzQvxQPj0=~;n^#s>7j41YHw0CXFNxY*Y-p*Hi>ts&jVTh<)}uBXGj`sh{4{5iZ3l&y-hwd>dVmKav z03oZ-U}$~?TdY#YD(1+Ag}k(_vsDWOCR`V1&ZPd?n!xy#Hge?U`cyKPB($1f!nYYo zVt_QsFFihACD$SfDDwJ*W3V+5_Ud{E1U&C%yI0p7d2;#lOAwm9!@VBNb(be5W7(s8 z_wnbyX))koa0>wJ+QgO7q}g}dm3H`3Naw2Bmez&&OMswLisL?=VCwLqT|k35QSLok{i&&jlBgC_QaP%9$>Y~~m7e(nTj zrqyMfVP>zY?>1@vBYlLxaz;eY*L`h9klc9fFka1M&uTK|cAvKA$B8E>Whn**)%a5n z8}(QAI(iFwCGW*`Qp!G@zf7Ja+UO7+3FGb}X#Yc5q(s&5vt{#6TM7cmcW{yxTz+eEZpAu8ETM z7BH(NA(YQXao+@8?rYf8$PEkeKM*#~*$~GUuiaO6wpSpSuR{CdTY_#g9a=)2_5$zO z4E(dv2o^QRx-Yz%YE>=&F4qtRhS&Dkv8BbLITn0}^|$`Rp;XyRgoSH|#${kS!_EE& zHx6Ba)VyR|xeuvaWF`_JwMvw00r1^{DUIV_2f-?AEqNz9-tN}wgu$5_OD;z=RUBg-SjBcqRunmefNdWr^jay$i^!t^y5vIAwwT2Tx;*mJHW$Vp2<|vpjctgQb`1 zZ!fj@MUy0 zMVna;u-w|q)m9L|iJA@ehk6s;`c@*EYKJqK&lvXK&#nRh6FBTMnCmPWw=xOm&e<{Q zY^wf3@q(#JEynAzs8%GeP(rgD7%7*Nw)cl5OPyR96NG1f&`G@P_v7s*3&U&KR>Dy- zGUgIc$zO}KQGYr1a^1g$-yI_f!hGcRUA{4H{ArYsQ1NfBqM7%5gv$BRRC^=!H8P@5 zLr)z?ErcD(lKHp9t_^C5i=9SByJz7i!Yu<%AfIo4ojABhuFmG8tJ+(!=`Y^j34Y;Y zFLDoTZz1LO9F7hs^OW! zKhB^`u&aoILg{0Z>*rC-V$Wo}V1VFogE)k^h&9-cq{rWS-DNXO84Om`Yk>$n14m>i@X0`7tgQi1M1&O zssM!At~u|xw8U4+{Maqk!xaiDbTfCac&`L)6~kbN>K;zWf5C2&cdJYGJd#kyj(7e_ zZu|pAzkf~e)#qhFqVStY28aHTsHHMb&~S#14|Aj-&$P2xX)H0l|L1>K)FQHH)YUUd z-of>W$J}r<=WVgG6>4RTkeCML8|PpLMtN%G3p@eU(9Hl(&V;nJkj(&hc3Q?X?eXvU zixyqwFs1QhY1dmwIx2MN<)KYV>RnC;Eivv4*YTI3cj3_^r*Wgzc-U1@bP0I-CB|~5 zceg6iy+u3-cns+1pY8OMIsb!c7IPA4>K_d@x4lGWE4Rz-lDTB2_sIT6xbA_33bjUD zD$8RC0M3s^DfL&H3OG8;bQ>0%T~Vc}C*3vfiLlb`Wi$9Y#1wsKSy^`GaHAwfp@cu^ z5pf|;`k$eX|1)QZEmZ_m_LIID5;vM&L9I_pU5+C&th%=S-CbV$T%rh{nR+ehk%dJx zry^dWcC#mrVp7iR*R;qnW9j&GUPAR3M4jPc;}Hdda1fI3gqw#C{_>j!DXILMQ&%1f zs0*VRF=%Ozss4%*!P;g8T^gUzfsCgEohB3C#!DttkiBQ&R#pCEzUNAE6Vnxj%P~l z?mpd{qTf1!mf9TzW+nNQ!^4zq`4yhC+vGf83c8GNuqcafOeM5IBe59UpSAtq^{4TI z8yKxYxl(zCV;m;3IAy}WgX#+ z@*0wq?zlIRh1i7u2`sqFlai6mDI)jJ0RR6fR^V@Ze*gol-y;QccaC)wbs8!{b}($e z_)33?Np)NvD`vSpA5rLve#6n)-#b%|iGv^7IxeoEs%#|%Nt4Lg?o3;-;K?U6cWIe@ z>mUS60)-ESw6J#cFCv)oV8YhDu@t=x6+ec$2~bs)BH{B_SzHelj=P%KKrR|w^xABU z5*BCc+P!79{9S(49^~&FM9~kyTYi!h!J<12 z_{i2EWExlkt{9i7sSd#Z0gaS4=RlFlLCk+;-q{Z?cl<&#uSRiQDFBOvl2d%yh?CBZ zri1|$b+z?2HB!jX^mzALfQ~T|;c7*-#3n2L=RARO`<$HT=$K!&HUx*7KC`f=$(zB4 z6FC*G<4!}Vn1T@C5mGCHclJ?;Ztbr3RFH42!YbF=PFXRuuiB4vI^GQLs9XpV_MnU2 zL<{ndEeTpZ^hCwI?55r+3|!M0ME`X`%2Y3myZyHFm_ijWd3)>gbhwvDu0z=#Ky!^~ z+t)8@A(OHs#j(0P$#0ev2%+O0(vl1XEKM{`z0Y?oPIz zYO`PFh05AI^(j%>Gd7QKaD;0P6#dA6>)$l z=(U&Fe|}I>Z@)O!d!`7hoY!K>u|oPMdK3h^(vG^aS4 zQU?efM&jjB9!3%n9s_A@CYLmktbcLGCnP!7ID%ITI4M#oGS_1yd05O^ zS8enK9}+TYkaoGxPMt}&V!a^&e1YSEeBS(R=^80;9m>B_q($BUsyk~fv%MV&cm46) z9sVQ)DzdA1uXE!&A^ofDaH&wyYr8p>>d=wHb9)DJ|0G(X2YQ7s+R+~p8p1Fq_vHXt z$7r-FArqHhE^H}Wh#1IpgP{*ZO0kHr-zY^kvR@@Q>@Mn1N6B`G)b8}KoyI!s!&(No z(jKTIi?~moJ?44JPufg;tcQeMyVN_mWK%YyBh?BZ)%} zo9or|!B9k|by8dE7*6r2#o8bj4SneeJJA;eSa|)+vGdWnZCzxmdj8GU0{(wRqk3q+t9`qo74*)@gZR zD0p%uz^uxF_R^u~b5Z9oXApNnZl`!(Q#>z~G70|6B;?VRT)0{iH@rRpW7N?`cO<6B zco(Wj9JEjz2gbdzyB}#ReLf{8!YeXkD=9dMy@9iKXKgMn2x$Eu8&t?tt9xYw@f6324a_w0CCiU# z+e%K!?spn`LYAmgJtH+789+hfK*x5zakvetqnEpV05O>e1*CnF$7)U6C4(Y@hvnjbG z&pNlH+a6eK>Fe>0JYFt!E@1o-|F2$vVHT9@hGfykg9||)kDe=*-92E0{lA@3P9Tj` zKs1d4qgiD~f-AJ!**$Vols3bdL;F7T!?Fr8aa#{cXP6axC+uzcaq{T3ql7IJh^xk6 z{w+}Z&LN`Cv%_`v)6FD0O>}dRC1~`k@nSzn>o@^?GZupHYGoc4)5P!1+BpnuyKOo2 zSHy`hGCPD|agn(!muIGv4@Q(_5U%24Ipzxsulls@G*k_4!97<-a6hRi9z2@fVF7ml zcb$67E14j}b@*&9zBjD^f3&EL$I0xulOgUoI5OXUPpMnD%N@Cfwe%vUFucbQW6&*Y z<5I}+;H~@7$A2v1%37bZs@3Mbk)n0;pprmZOR^8SCijnJ-}8==dGh?&%%Qb!zF66- z$sNxD;X=oPb-e3U)F%>p&wDZC!Wp?g3^@*Rj%ik1+e~P!W(L>iCjCK)qaA&=n!0&k zqHUiRIT13{hx$&n8}xc>c4o`Ac#Ejihrb#8Ek_dZdO#Iv*kHR7bxt{)yh=IE-Pdn9 zecbx>)EWFszza+7`~MQC5$$)VKZYnco6#nF4MBGK)AIoiN(E9HC0UlsYrpGHPK`d2 znrKC>6!gh9PHbQ>$${#C%q&C}$5TX-iywEfW&EWS6$?zAtyU-o;nZ~9FJn#1k;sn; z6EqTn=P=}LmwVHssy;Lv{DQwJ)4OL%3d7RjM}H=^k;uIsxG;)B>2BV0);rHkc9zjI z2%ePcaBp(M6)xsw??3Rqs22B3L;Kxco2h+BLy#}&cuMpg_r1$aK5#qZ#CSl+9SXTk zA6i)X@zfIja3HMay!HmikdI%fI*xRKD9eC>l`}Ixf0P>s$ePxwufKreIsq3Mls0E2 z_@{q&_P4MrrN={;xLQcO>Ye)~b!5x_8UMGQGf}N))cO5Y_rllo8#>=bvDwn4lw$O( z)u2HKXF`bI8%|<3FW(DqhCSw1tU$iM#y$Ji4G2#rcC+)pY&LnP_0&~i>Kp{3&IhPA z8Sz_G%QM5{BcBSpQnKq0M^_$A3mt*QbpLYtj0-X8V*5uHHKQy3Kbo~fI5zszqBag) z9L2a@8P#hoWtVQ1iESl*YT=l$!GiCTxH40?Us;>SaWkZO)4n$J!j?j`I)~3TlcFhX zV;uVksqccx>x7{EoNc8?_B3_uAq7vNHRO&+|`fTG= zp7TH3{#aU{h1|{VYShkZz}t<+QF0Q06SRIfh{`9~V?;Qf9AAzt_`UY>QoqrzBj{y5>p8I7Q0D5%wm(U{T7rA6%TcK@jm*vG~q zPwm#F0axWxqmy+NdW%>a^H(vsjCTF9Mn!ps#vhp=I0zlaAjSmdo-lW^q%fG zyexWyVdDwmx$c$nQv6@AmUI5|h4n7ZR8f?R)blyM_r zi9Y%Ca$jp*#N;JYLt8>2_9!a!@fS8jv_?FBfL`l_1))6p80!nRJ3-2uBkkw!cRhtc zNDa)v=Ewx}@=d9LSxq-F5#dW9Oad8N`n) z=PF_plK2~euJpt;0pKs*zo!4hKT=rI!mfTwGC#6oq=PZ$9R;=a&VJ^b|9{6AK0V{7 zjuZ>`VXuc3DKX?Haec5VDR0oyet*eUqSK#@ewobFEXn2u3YCFNQ_ruK=o?w4j%h|H ziZ)zY`@?wVU9J7YL}<3~uMMPf3^b`QGs!AFkj!R9(kb?a7ro3i<1vIj7%Ju)M%*e- zpw+GrE!%0?SUD1P`zziB2k~w2^)Pz6NIvYJX?an#k@^9z0XCJ&0hIo z%;3vTUtg7*z(4m!w5+3)?ItM?9*0g3|Fqv!h2-DO-L%SJ096h$(Rv1tlpYF}hQ;#N zqYm7x9E(x$V?-aYVj0%h+ieW)iuix)rVG`#XMRFm_PU`y`ZDE^TjbLDFgEd3Ekw0~ z1j4TL(E#63)`OSXg=CUQ8Zf<7YQPKk)O+>`ryc}1o9{8G%1UjS^M7Y|kUK36xy<4` zq~Z&7n6FX8&ARc?MM}#n?*_jrCih+MO)bbDAkCAxtUr6VJfrA(vp=0p+REO3Z|iw| z2F62O2)~Hq4p$Kw&d`7hj-hJ9cTXyx5R$vYmWBjto9wrC@OcZZ`2Am zVm{GyM};NI=_H) znjv90Nj{pK>0xHzoOJ73g_oXe?{Q zILG!kho`QPf1oS!DFQ8OH9n81NwMYEWWOqbN^MH>&pv2O2^WjkOr^K5M8oZ8VM6Qpt@dCO>z475JhY6=_#79c(W7Y-B+4_jJZb^zst_RAyB5klCXN-*wO zY~|7B&!gt=Nmd&gs-o3-fy+K_PaTe5FhmTCTXyGJ)rxQB+3PCg8EaF|RlcRgt7{XO79#}%0PzSD{fd72{S*gQn0fo? z&+0Hjo7Z7TYfc%&SY1b@Q zyXs$&$rt>J#L0}#lok7o@;DC?UNJ0qY3()*uIo^ce9^I}{@|-^>ejW1!%3bJ%q~hh z#MSa-Yh?r<3#PU3qg5pL>3*6iLc{h6l6v{kgnB}cxMxkveA@Wrh8BjhkHnkN6q zJADiF<==b_wcdstp&d0A7oJq>GMDPCLE4s7+Ma)UF6KBOO|O*^d?U9Z12trzGNzYT^AX&A4O2z6q`gHCH+;p0py6QoR;RGpFP{ z)SBzo(B~5=@S?8VYQ&%I@xq(8`5IBuruq-jmIDxNULrnwF*Sf6oaXM{K}*I5h_)Jo z1~^}x2LGZItL8HYXxbflyNwD}?veP#kqb#-{*{$$Y#ESA-XCy6uW$CuKz4ig@SSfD zpAbbKX<=*5=|DfkpS+h$2uXpMNmqf~%*c2bqK5DtD0%kwkFbMo=Y}SD?{5zl||&D>%(o_DB*@#}-(Xk?hn!%KoOP%}>uCoH zt(ASR(;ftt!uuqj(b%i6H~961^3b@*tZf?wkIxb03+i<<=?mr#;*RT6xX5YtX+tfu zRi>`Cz2&n2rL~^unt^8UkgNDW{(~blczX~c^t1Lsd45EN4ku~WPX7?-c?}VNXv>H` ztVHM1C6>lpJ$F56&7ed9SaJ2%+%v6*zex}f0H}m7|EA3eVO6gFhY~OKWHEDL;kR1+ z{5dSs)oVjaDY5K{$F;==rh3jnM^ccu1vGtEGw#0)g`bd;EJ~~J;nvN9HyAc)Iuina z)z`-Gobv!}Gw>q|deB|C-|1@bY6S8{Qm5?lA-b?f8!d6w1U;itoDL$-v=E1l%(>fh zWS6Ny>ob%N|J~PD^Z#V|ad_`AAj3)MK3*mtJPhvASsNl7+ap=CNnYBgB`H>wq-++h zEk696K4r&PFuVN%U^fCem;j91^peN`jjf^GAuK2*;t#xmKX}<&8rDDfxhu22*Z!WM zD%6mb323yW@qg2SMiKD$&HWjDHl%&Rd`n-le%kCT_S8cjl7ap-IGk)hnQ?oN9y;QW@w7L%&p*q#qzd=; zeJDKXP`yUuRK26HcB&b2|Cr#CDv;Wuz^bo-0(Sc5rscQVod{Up(~NT*xn-vMw7afC z1!s-XH{UR(V2hO$?g<;Tbv~)EPno84&(YHm0G!XdQPxc=vcK%WQkQu*8Tbj;X_a0@5bujzUW1p6`t zpDp5x;#GO&d@C~MU5_qVaEJ%z8h|-w~nQ1ISg(I^JN#^ovi6}o6E~1k=ya#nQOu-SWQkqUCEe)>8Y#!vzL&MYQUQ{K9(<4v^nNB zib@MFxYD(WWZ~qX@djAZU0$;0s@-E_MIYC2GsXi2``2AMB{J&Cb_;%TIZ-U69pmzR zKmO$#rVo9ejo#sztW5Rh@1^xkDv;E@>~3MbJ$27sevi=p@m4oV1?X^awZIW=ayShV zi|9S_xdKJ+Tawq?2%)3Cl2@qBXR1!tvR1C`t&jQo^@T!ci0p&#Ca{-L@rC+d0@L$t z!mWzndXiM2fg)AX;HC~$Fe>6uaagc_?7$+*JvJU!7Bl|qwIfgs1-i)Udb7x$>uxxe z8LL?|f3Iw$A$}&zgjqsVek2`(K7#n~x-Du>a(RuL2aAu+fPvXIAu}t0Lt#qSYdX}Q zOIRhA*p+w(R5!5*E3`VSe~735LZl2#)nP+f^v+Ff1s~aC9D8$maz(8L`}-m*MJ=C8 z7G)rISW3LyTX^{Wx$7_PjMGY?-~7TueVaisN1~CgZ^@*qA7qXH{ePh(Ac%Yz4-2jz zX{cE~uyH->w{Cz2B%NeUN^WVsQ?l&vEuEh@89=TVI<$Er6Th$2c$4jE3;{2xksa}e z_5Eo$SI$x{o)Df!3b#eFaoIP8;KQ-SOcyNwhz{jMYlI$qlH@b%Z|j|OOa=Q_8hLro zD_72hrMlFF=&%95ix7FE9+>TJhp#!#c;uGeD;yci!4#?1m3}LNZIIYOOOCzpxl$)F zJbU!(HjzX5ZXJedr+KEH_D6B%EWR-nHJ2aOgev5|Z`yH6UJ$mJxm#aFyJT*gt{n~# z`z&GcRQq{vdHDbhuE{>}TUwvhT!9)>d%MkH$fnTjGZ%9|97XlqON=17Zc^jKK}GW1 z)#h9u8Mt5EDR}Wj2?W@(K~2bFpvp*&SSAyqc157!8+7VQ zTkSSL^<>{_=ckziya_CTw+6apz{kJ$cDqX(ur%6G;x0UbG_(;MWPx3Payg!pii8C5 zrOqC7=>M_KDWwKlr1l(IesWa_*HZNV!dav`JVllP0}~ySxXgn7q~5)Tk;`yv09O?a zH|R&sUfTe&6!WKDqeom=KjclFWksNYsklB79dpCv%6_>>u9KL;xdvhC?H%s<_2d{W zyugIxZM;J>y&P|=DIVJYX;1QmATagcB8m>?KPi?`>Feua`+Q82Xj_F2~kft}yzlu+JX+bGd z_d2K+FeR8$hN$LAzcsSE!QS-n!{ANSWu@?ctp7b$>KEXB9}aYJ{$}*E!p7px39;8_ zxSGCps&ND4?KHO{*&izQnxV;H#l;s%q1o~#DQLVhA920H z05W_}ikcMZJp(x8#__CGJkL0i*TBN*)Pby`#HF^Uv?VS&4b}9mC>Q&>+Rti_+ifm~ zdOGb=kNER*O*iaU_8HPHy=M#oE}N&XaUKMoy=lQR(76|q!eN-Re_{v^fv!d&B0e5h zYm^O;w*MgHjF1R+I&ngB)g$$a-Ue#52=f|s1u}0|&+o@4t&{2tf}?_eM>R?SSN!3{ z0=D=yma8vHl~W5HwOJy_hO>6wFB&0ucz@5H#24*a;G?$+C8dVytD-wY7+weN92bMm z-r-Ynt00E$9@AOxQ!9D@X`5jB+WyE_ZI?LO#Rex^h5};5F9h=Lh6Gco%c#dL^dD2Y z*dVC)1UY^O^-O^jioDGrUB6E`o@qQuR&*|x-WS-mam<-_50KDgStVnwso3?St!9sl zZT@-d*;q1RfzhjS%-BlM3@hbm#!IJL9FX2Vk?6Y!c3uQ3k_oz}ig#e1f=R6fWZF1F)Z+k}@qOP2Xw&{g&j2y~^f9;ulg3@>@dFINE$I zkcegJjn#$Ks87X}&^N79*~!N;4T5b+$+hY-7YX(DMwX9#8z2@5JD61<_0i(4>Le=2 zx0up*Ix&7;Rc-4g^CcNEn??Klsc(|=tS$)_jc5=b)~uM+D1HvAAIe93KQ6HhzK5RA zEduPhC7@R^jQKGkdFoOS8|TZRzVFzSh789exl1b6J?2oMS^N`rAH0?~z|8%|{ATJr zZ-IyRLgN!~pprPe4CZtt@I(C6-^S%zF_r*Mo-|58%0=dVG(?^XMxHsCr?x60S;O4Sv`?f)5S0l_Upc`38H@_0gVV^ zDYzo|(YWAC;;5IE`La^_V&vd=pp@Nm`I&)8Y8vYP&{8V@{Fb`t+?|H%GkP*w_Qlwh zSyHmtF09W3WO;MLB!tzZrS|jS7gdxEUTpK{drwTPk}~oi+4{w(MnhX_7lWnOf|=qZ zWgZQSQU%{}DiJ0@UXy?pn(vmCU$sr=PEF&+8M-pZo5wC9D&VO#L3Fp8`VIx+6rxS~ zpR8|@uWDWJ#ZNSvmiFS-8hbUTtdo?dUUO-u&f#jjW(Zt$ls1`BI(61Mrv&5&a(%7@8TT>_hxKE8llN2Jhu>7`$&O(EQY+Av6Cle)dVunZMAMpxav%lX z4^P*xeFxYFH@LZBXrpJGtx9HN^MjSmZAow_zj*PI^e47H+T({A=ik%A(EWwdv)K~Q zjxNRM-%;+K15pp;0^t3c~X!Hh0<#Sh`*jr z>swY@C_X4WoC792f#1wwzyjBSS**%M`b%Ix>p=K*&I?feZ;WMQcR72fX=r`@P0)Aw z$d{XM3i3B*K31vrRP0R)lDRM6rR*w7s%GMJfF;+JK+~YU^@7d>$E1)9M3^Y3CASO% z!RJBmsAvCk{q= zODPaYV6^+$xnePpngeT=ujkgC^MJ)5PO;}|?en2NR%AN6X^}~!a}PV#_&R5q{%6_5 zMdz`A0xQ|S#_ZC*(E%yQvL|+Xw--^pBLg4M&Uu`+nL)4JN!_|qkYUTr!8gs|k+T%d zTcc6BvZvRv4BW@>CG*vOT{UM|cZRF2FMc`lZa`EWNj^x3L5Bfaw=uK7`f&JVbd)Lh zzHZ0pu)+mjTC$(U-K-v)rc~`WaScc-Y-NlV(IE4bFHVe(@ML*sD$_gBX3p5b8qNi07q z>BMJrso3_H)`^K>A!SlhM}U{t3LJ74my)&ZR7NKEtNM%7T7M5j7-9R6d`RPq4z6y9 zDws9Is1@RUBNdn*6~8nAwB*@hkALQY5}Y|>%`#9b!#84y?Y9l(N;*e{c*#h*g!c`C zM38g-9Oi|5wTflmw4|(2lY9i63Wh=2qLj)49~*&Eqn;!>fsxM(7|F2!Uf7Vf3%-~n zR~-AM=;sS1@SdzOWz9%UdGwqva?j`DMMTWlR;Ix@6IV1zt_{UZurp3(!HkXTE3O-W zP`Cf$Yz@&JCVev>u}hwNO;BVSW5Xefa16(N`pLN!s;YMMa6W;|6Wop&8S-hOYz|E; z@nY$!c~1x<9bNbv;TiIq8#-8b0lho};6-2`dMDH|lMBB6S1*8T=2p+{ToAFXZhIC^ zT?joTsy$^`t%ytz@elmGOonnf>3iRDv7&V}OZg~99PNP7T(K@2En#LH!jYL)G%G$# zq7+Pv5LG&d6Tw!ml3%r^rc!mwM-9%aeW9=P$(` z(b25g0@KLv{6>Y^iRT6HU6SEp#v!VhffJ!>HLsG>ldC_vPz_$MNRAzzhJ={M{iY&E zb`k*P)bQ2Zgn$L^#+|=TT#EsrYO)O<<~(LG;JpH^HDI3<(tA|~?W6CG2PRn*%8XAFHzN2{vQSpA!aR*}Sw*ID-PpQo1`q}DN*_qEbwS%BO+76uY zgdR+~JigjLU+m>o8!>5ri~$#lYMI7?eLIa+-&kvA0zId;B_$EgWx-$6WD=yByRzRC zxK`HjeWY-;@7}sk-J%D*#Sv*y_91p5MnQ5#JnF*J#jUYXF%)>bJFN*BO@95j_xs27 zVAijyz12I7a!k!WaaP8$urm{?q&^pgc%wet)RCFs1+2q<7$TAxsI>l`JNK^anKqC(EkOBT3@xdj z;(7WQV#dV8ARjy#a2YN6yY8!5=l7IM`b0h$>5ZM`9c(5N%Rog%`g5=qAutlZ#O|D3 z@-OM%_n{$i%7lKNlE%d$LTIo>*wq!60!W9Ja!=B@5-`q=V`6!9co?P8kN_4xJ*6m| zoj+jEU0-M^m$73PDNnUQ8kC)^ZEq>_7!If?PxW2!V_{ipe=$G=Rbb3#fJjN1C#iZC zt~jgqdnvkUua)?3-#d`V%cUWj**EV`h9gjyM*wT9EH7J(v2}ef$z`9o*ZS+({uX~{ zIs3?oGL6Zw6D)MY$Qbdry?x}C*M41j8Oa?aVS`a9^SZmk#X?sV-oC>WDc^CjF13#( zGK*G57ltece=(e{Od|wi7ANtL3q0i0O;+Gxpq6tU{=_V=T|xlS581)e5Mv`XTH4x? z)1%RpE}Q#>!i8RuE1W}Mq}M;3Rb)M*24T~@Oy4DnMzS~7wC94qS1Or6Yu|z#3h)OD zUsFj!H*J}xvC}wuiHtAv4-$hLQu)5*j9r*?l=3F*p6|7Fk zu$q<+DX|yA%RD$iPlOHB|GB(0arM78eXi4~-hRb7W^^qa5M)|c<&+9q|6 zn#_8XmszU7Zz+k|%d*{)HKuv)pNb@|O8amyTgb)sX$@5w8NRRDB%j*?FO&FWQus(Z zF#=$#Cw*5-_M)nYfr?+elzOPZ8?NfBwCT`DHN}u*^WP$hp`={BslPM%g&@E5WV|jQ z;#oZ0gtAd0ppd%=k-4NV?`~X&UgXBOAi&1c2V&!SoR^T6`TvBoKAGsxIJ?|am~}-v z%!dA*36P)s&OZ|^Les|>P4IuL9FXMTjQN5)uZ%}ii{ zDXpu%nV!Qvye52YKUeBF_J6o~>#(T1?R|LYF6ov51Svs4X%L2xZt3psmJsP~q`Ra+ z8XUSC>5}e7KBftZ2OZN|nS5}ZMb8sy_ zbOjV{TM5is?7hI##DN;R)1V!xsp(>5IHCM!ekAX;4d4&)2Q9(gkMSKC;r8&1HLb7G zT#yufSlQQ?m2{WRzH#5Q?bQQk#Mf>rY{TX@=P9JEyHu?d*^v)^M(og2*>XbqtM;?t z)M3u^>BkOnI>}#g2*|^bQAr2O_bh%p^}jZ*!JzOazH1y|z(iFb( zG>;OY5`0Qa5o_=^apva=bB1%)#ZKIjEw7r&Wf09!^yg^#3JZy7mPsC>i(OHIay{av zzr`{Ly!2!hT=>*t=;RnGOrPW5fl%ayR^NgBEkAxWEH7SMJ&}Zx9o1v_BO!GgDxzy@ zHLvo!BAC3=t>`JY<($BTBmwF48BNR37SZBtqd(Ej-xF5TeTikPTLZSVz`{~mH@QE;Q`H6LgNC}dzC4lqAdCaZL_=0m~fF-6xqsz;oV+rV9 z2t;;1Hq7wqx7X)ctb3xuL)Fx3W*B9oL@Y_ij1njmMjz=mIrnY8``4`Ur;$1cu-sX{a8Q3VnmW3qpAVPryAjb2Ag9ZWh?Q84c6{Lwo=iE>M z25=Qz6QRh_2?c~euc*vd8S)O{_UlS7F5YBfts*1~A=Vu5u#x<-IS}A5|H)wBR<-An z-0HHid&&?*caifqpxWcgFq=&wI1&Rdy9prPTXRF{jLQwm4SvgM?6BE=*O-LZDB$3ZZyQ+92mBY zJ)kLr^G#u(qM(mc>Gy_D-Z&O(=iv7RSPn)`m&#@lihTel?|;V zY4JTI&fiwVVJE|J33>TOOr^g;T&)V;VdO*<@G;mrQU$mHCm?1iW!B2&JY&9W;m=cI z@}`ELKdQW5ZZC5~Krjy77|}fh2FY zcRX(#5~0Aa#eh{f43Iw#FW3VnTXvb%K+~4abpIT;pGF#3?y;^ z1q*pRVn0mz>oz+qHQCez74pJ3&RRn{*bsfNM`wAeonpP5U z9tkcc%5*#8Iy!)^fE6AHkLO7Q1*-Os21{z!=%A1zwy*zg{lL32W!)u*{QU_D&>DiG zh5N_EiQ1R9!TMABu5;c@Fn?ewDP42P0*Y}�mz{Kj=r5VMPQ18wq_=qiU|J+Q5Ga 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 z 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%0AHkUpDlAhx@Mt{*}PL r68KjF|4QIr3H&R8|6e7rGqH2QM0pWsp8O-~_`!;DYO;9`pT77%(}E@I literal 0 HcmV?d00001 diff --git a/docs/logos/title_logo/title_logo.png b/docs/logos/title_logo/title_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..650dfff608a0c1ac758165d5f193075c576761a7 GIT binary patch literal 574995 zcmeFZcT`hp-!|$rJ1{7MN|W9}1*C)0iv$V1geJZB4$(o1ROwOzLkYb|htL!Rq(%~Y zl^Q||JwQnE1)b-8zL}SE*7^U;UQ3g`vt0N6YuA1K%J%IGfD+mD``0gBx6Y82OIPZyUM4*;64yE+eOz-@Ht@J~iM;dt&rdxrMV6N?J-DPI|4iFAb91ISfliP9 z;{p+l6b!iMr^oZ_Yqi&rK+AAuCZwAFBTD(5>(vY)S1nGM&My5qzz%%M&giEp z1um%H2L?>)teoztM4Yr072;<6JII46Nelja{i}h0HSn(n{?)+08u(WO|7zf04g9Nt ze>L#02L9E+|1UJKW3Gn5olE*Lpz4JE1q-vZ-@#}LaXM;twzbP7sZkeLW3)b!Z|rct z@bTbY(uQbq&ONn!^SRZke4zsY)97%Ct-F++OQv)5EYMSe$+f>J?c3}vx1TRvdbL$c zVEb5ymwGC}^Ac#u_`B%n;7q={7ul#M{<4^LL zqz7ga5cy07`O{?%Q;ZU=2#{7uD+$kOSE$0K^qKpsP~3gDM~hUHrSFj9i$cP6444-h z6zp136v#a<_po*t{J+N^Hx{v5g9{4Buv z$S6XTKCVB*P40P*2Y{9Z3e2qyiV;eR$f+O3@TPW_S{IfMem$H&#Mhu?FpK8gdPIl+ zYcbN-xy*>$qz}jJr3fSkMO(Un%-pm?e|x2{x(CaOXynMy%ljv~At zc8eT+*Ut=~%y@?^j?j|-{LtjSTV%xeo6Ip3XH!;tDqvPk%-#<19=OE}hrQ2@LtB}K z7rP*GqZpNlg>Y$lX6X2v+<)vL`ma5xf99Gc%$j=d?U`QL7m_Fwqt6%OWv<(gc!Y4h z*SG$qM=X3X~9}bzC!5_N?&&34nLmb9g zx?=1i6@->r>`;C>mXZ3Q8T?VA3i8iM3sRzF0W_p_U%N;w)*7N%mk(eO$h0b`NlU6j z%&nlCPzUR+YCHS!`j=R2*WmW#cmvG${jXVnliaNTt>{mr@S%obzxCHk%%n-lh)(cW zwcLw$8B6lcFiii#u>>5fOW{&*`~%=X9E{NVoc2yjJeHR%RVkBsC^WPCkl|!R9K{UC zz*ms;LBF(oI&!l9kld>T&%TosHU;;X#~-XYW4rJ|AW4zGt-V83{2}t!*?EH7lh%PK zSOzuTF<3j7?g?~Lp_cMdx7!!AcH~-h1ajS=Lhr4p!e{Q~gP#;LW8tDVH)>3cD*?#6 zc4JU&SV8eDVA50?)3?{_vVA~_7RLPgx1|CjeneEpNc!SF9Jw{!{@#?$Qj}F*6T^Qb2_L$Hz$#=8oyB6} zM4`7TK`zE)_lrFT9gV%M>dOYd3ePU=^;TjI;y*+Fw*EIcmc{cOP3MmtOwtUWolN&g zR9axIX_13&kzGk1(?WGI5fSQdit|00Iy~9Gdh(Oje3TU>T7D$?mJt$1+E0h7UZiNb z2u$`fmj%5$jCHX#JwW9Py83$r7;`Pwrn&o<56;=8q^j#R>#3s=O1S=GCC|n4SpM&| z)2{wQ=e>DKCu226`ynAAt>RB)RO@6^*{J0J>ahiHBO(mSH4(icAxO%C8v-#-z0m-wuYv)_#t+)nsOyWVoDSIl*90ULTTnx7+f|Pk*q!k1}(& zF*AAQ^Gj1k)Fs7lH`!?GA9+xI5ZM2DAnH=$v(LV)Rb2fAQjzK#^6`V#HO<~zby=wF zsMd`Ziq^{ON(; ziWGrjM`G@EaU=}kI$C&#G%P;0{bHvjL(B}B2AsW1^JE^u9}{8pC7peLJHv1w8fwFDmpH(w@K*HEeT<%B6|HEv*)-Wy1))u$)9 zo=1_cTC9_Y-=vIboFgrp=M2A;sDZo-wREKGAWDzs-mj?%fJOFp0us@Kk9*r-K|xEs z<&0;gh4oe)fA@yJ_BO-cHck8EyU)`%*~D%KbsWKL0^-D|<;0+658$$UUjWKe?w-Q9wmqSSt;OkzAltMl5Nn2 z{MU?ChwXqtKa4mGVm7jUk}ii6zyD$?hQAKiHrL)$4{989Y|UfhMMXg36`mD#1=z|W zPhNN&NZM)MwHF_)z4wMyjI2SpsR<$ymKgImt;L&A*>H$ZTlsSHe<_#rV6D9AKhiYJ zN2MmW5ea6Zvv*@x?l$jorcS(&TKMRZH`|8bqZJA;hyLci9w@ZznR&aMy4QqCb1JBP zLa9S7$HQgQgj^7lZ zK|D@PH=mQv4s&apvH{X&jCy5W?&MZk7f()Nv9DqkLz|u1n7%nTy?3s%0${%qMGtlV z))=(!#W6I}E1?PXmaY|eFBBy@*r5RBzb-y#vrc%Z#W5a|!;WfoK)c7bdeDQAhqfPj z#Vp#$PQDOW>$W-$jq7~NPrl@ej*u{U98wST@PylGTH1?m2=GOUHog6N)K;0g$<#bz z=fqy@s1atg)axUS$G`H*6{pGwKmxUK+b!KG zPw%r`{oJQ_NJ0eib)DYk!qd+xu)251Yx0?~LuAZJQ*>63OEMB2roHx7G15}WEjzv! zZHwXZ;OyLwA4J_!juxL`+7E-rCTo_~5S%MB^2L4)8#7++Ncp6pCTd1 zHjNX~`e3HkzuSb$SscA@D>_3a#wV2EEemL9iL0o~`1I#gNO_Bwbt6|08ryD^NZdd2 zp3E|)T`p(D@0`5lZ#pu%cfodE;bm(( z4O`rkjiimL7GJz6jvwJ;d^<8(XPU%WI0Quv7(#2vyW*KAEBaZ~dfq;i`qK9h%E{nk z=Mkvtzl@SOo!uc4@LSsJmW{%YKooL5dkvb}H#_%Z$$#vp;l}@LCCBfhT{!(OKH|`P zq^i|j(B|dLTxuiyqOzt_VHOk1bmSeu5Jh{6l&Am5=>wszW67kFKp8RCwZMykSMtAeY<^j@s zpQjV`+6jljZ2bQe^89|A={q^PbjjCs^DH2EO*UTud?Z0D(p)XJb4;l*t<))<*jcQo z9(#3V^_wS}Ts|{jj;T1*S;^JS}q7U!>{3$4x63a}l$kAgh%5&U>A8^xc)sLGQdYAK@Cv z|DgRy{}AVMc4MKieQ))ti@gw?lMM65@U7XCB=;p?O}eB+BR4CTeVP=MD_oUBWs1?w z9Wl30dqqwPfFm%7<>$`Uu@)z)ic?`H8@hQHg(M310}HnDgI=>@E~iXQ&QU97iWhIk zDG!d%VAE^J2fW0>rzYK^m;n{x6<#(PTRH8R`IpoD25L&C8SsX!Pjx8};zz%N@)0^I z4MVDwbbzqaWutIx`+a)SzDr<4bMqDB zBxv65txFud7SyiQ(5T2kFt}#;=~8j{%K-7A-n`jZ550VLo`kZB%pJw*bI7e226)=V zpF?u=f%%wZEXi&Y2)x-uoiv?$-wGXc|oe9e_{B4Cj@zduzgaOcE)qdLuD z@~Vq_?@1UeVC=upe6@Jd5YHr2d}E z{xArGqY3yb>FcZTNo2eFK^pgeW&yC^YcKF!scod48pJ>i%Q(k zAA8|k2F$;o!>(3m=p{h6EJX1j8Nfy#%_O!J#n?qi3tGsTNaG6F7{n=GVFut(8-O>q z-hX<;&K4osisF6pl*?5?RQ&01x(Ly8H=ZIyXr-bm8z9sQsm5`N$=-5mTWytCOqCNV zz_d1toL(kl{+x!}al?j;;$4I)ByKXk*YgcC>0<@HrC$|kq3+vS2bFKc}a;JLb5Z7Yx3Sa zUAzpm33Tkszh>s2>|2F1pCKEvl{*_6u84%+C#Q`A7P^%et2C)vQR_{SiI%@}*ga2x z_~&kj&DyA<6c(VDuBpy;wgWL8v$02_u0Qa8Ia+Cdb74QE@*5DJwalsdE zEsXi@K{UYN+*A{8p8)U=cmLbpuW+}&Gn;QrhntKogd*AYqWkUnRPazCWfDMsiU{Z1 z@{~g4_>{7azh-gzV!wR)K2b(j%2vt=zuZJ@JWQz-HbRjbQ|)}HlA|08y)m}Z4_HB6 zL0UJ)tBOO{6yr02UO*jANT4cT$(E<+St48c+JbM^s|}AV#H622qn#$7z1Oo6t}f6v zO}XJ+=hJtwqe1t{^X=Mm%*XwwZk~cL>F-9FU#cXUNHj~R4yxp}49C>&9@N<+nGnV- zTOLjLA5_lG1|K5E_40fECfZ*&q7mOqW;-7uc51-BD>dAQsg}e9+3XZL)s$S-wyuwA z6W*NpJAv#8dyY^~>D)P2w~T674;*~f+Q7^X?DotpP@FeLuOVCcOjdmPGPa!65` zqG;b!Ey9fIP7#7I>XjzfX!n@_`6k8b(7iF*x};76M^k=!cPTASTmx!m5&fGPpk zMF`dgEZVf)awoFnRsu^ts|xwXFQLI@_c6BL7#p_;QT^YPkdN)>15s$*RAU=b&au#N zviCZ?yQZ>5I+Nfl#DMB;LQllOQhI=i)lnXv!eU%WHMv7S1LlaCm?%-PmC13a*5rvl zwlzqw3T$oqE2p=0-<<#c6Xfd%B;f>hTVj1#GTViec{2m%Vwmf=y)+-#No!#-%^BG& z7$X}}>WxPk{SMk&_?XO!*({#L9un|%TY;FUF^RkHrqbr`?fzjqx*)ZgWh2gLPoBlW zcimLohON?0P!tw?J7+7%v{H;XH}ivFPKp)KWvYj)d@8b{?oN!cuEtMM5aGFMG$HeP7wGtv&VSBnjtxNp;%Jj5?4-ZsFmSV=Azd43-3@J7x87tr`!Rwx6qpKi{swJ{z{1;Vzo-E3iOh)$))Nl9zOtR7E)r_= zM-EEy3Qp|BGVJT(?i3y*D~re!zJsz>#;}I63COT_?9JgMekznx)0fySEP0)vp=4Wj zA5t?XpgBLksW={d+MUdd4p{;*z0#?0@)9b_7mdXCXYkWw@IdaWiLHt&h|x_@_Wx@8QTPVtN{ ze5JcNB|9;K?4m<^?7{a!5yGB))QwW#c^wq@7E!hRR ztx=q=e!^-M2ajxDiX+ak{P5bSCrh=*Z7ZWIEPzMZ?C*Y}Q#%g5QMt+}d8nPYrU*)N zB+nd@PtpWTYC1vvzOai#PL-31D)4#3UA45Qi)|Wv>Mgnk{*0drSt%qDt;f~11=Mx6 zxl}%WCx?pFAc}1dr$)Pn@22XGl7H^7m(DxSXXIf$vR@2;hNY_J&VU^kIF*pDY7~* zj}dJr&se0&P^A9`T)SFFL$o8GxA?$tPn5D?sqCJD_o|>x56l&rOUg<~;M<0?O_)7A zZc3&Sn}p5}_@+zVV}KNEVHAhf*U~q39`UTcmc78oF6sVdC1eLYa9={h;G|S#ca;C^ z8k^mmS3f zwAv&TF1FzuZ^h z;%jO3U^?Vtx?O)-WCc&+oB52~lX=BDnnCP5C5uTq)hrZhBNV_%e^hs4X(2mfsxC_7 z67QX{(5NvF8mX4ksSkFS3z=7HfYG^$kR$pjAs`pWE!8M)iNolwQH#RhXHd?rAt{#U z8)MJAwrRb^hx}A+DzAztB!DR9I=vPOlF` zJZMHIBZ0j{WWFKmObo5U9CCpPUmgB{3GAC48p13#%bUBMdfpfBpz7~Q%;4}zm!m8= zX@YiEmS-4vW=i&h^sDo^*?Ksa6*a?Ef;m>sdW{q7kd9b_!-1FN)3KB(YS2wp0g^1M0Va%Z}WNaOWBNZ zL+Y4gYrnv*BBfr6CKVv3gVWQuYME!WjU>oUXDe+dY!-Gsyy2wrej6WnH$DL&dVjgKsnIBvr$F}-;tVzLR{1Ye@c*Nw7}nExdw@4V#2BSY3ahO~Ay!)$b@ z_gpiGVN>_tdLGF>IU7H3>+@Xb_C{^KL&AZZiVV!i=%U)ejM5K(^!X?VJ_bYF{Fe0+ z@EhqVsAWr)$VqS@E2(bM;Th-p#mzQb(*{)h%N2I64qAYli4Kd%6!2}SSCTW~+Z7Eb zp4=~`FLf^+NuC}T3$<$YlpH$dOOlUoQUwebWI|yAf7TWh*1mAguVxOeeb`ZJ-D{sZ zA{eagcu2+$wHR%YRMoab$z@l4&qO)&zGtFY*W_n8m^qpcZpg$G_9iYk763S!;p&j7 zVXKY!k|P@#5A3Ij$ep%%jg)Qt{NP1Kl!*EP`x%n>0I=A7=b5vWh%*Z9ML#3IEGKd6 zOBP-d%oZuyVmlUyg-k?Fn!N=5djl$KaiQhVPf$_q-0KEMf&EPVa^Y~D@fsEMFJcws z{RfJW0*b8GKxiA2dMbwM6)RdczmPG&<6#hS*3B_mY2Cus?nNGZe~oWqeo>c$F5*mO zo91pwwbp!^Md8XbXs94`!f$loDRA0GMS=S$@&mGpcS12~$b`~4lC}A$CA`-gMCrXQ zUMl80^k0}6i*#(BDby@Mv4UEZ8~;AA@I&$se}&$!t*#3i8H_R~69TL!zjcl2u^{xX z`)MAmMF@96Lmi84=w2GUL3a-1vyKJ@%r{f@*ElBM;bOjApQ|XA4J7!gTHPN!*y!EMWRRim=U?RgGy17GSI527FTc17MM zXP5ezJF$Jw*pMd5(3XTL95_wdbASx_so1gb&O*V|l-<{T|7oXyhwH*xv=UzUA+3yM zL8Ip1WL0R`K@RR_mSQ7d6B)A^G`W!Q%uAb%588KRmmAt}*LnbE_K}90Nde_Fqu%0YKgF{pugKMV@nmP<;9BAmmDff1N)*cZm4!(~cBx7q z%|^7GxGvW2ybTo=2HLO_)MtRMJBkeRSk`^fN92zWZC40If3+n2Q(YgtlbVbG8r|H= zcR?_Il={KJzIN8om@sztZ0IE;Wa10KrVA{epi9@(IOzDN7=bXIjSfJRqHL@J>g~xS zMY|r`w7P!bo#8jyvglcu@JaE(P>fCXbgW2zvGt?U*3nnt$xs@CHu}`G<;nBB{m$0~ zOBJ7s?VSqltJo1HHCwR*Blb|wohAdM7O;9$5aE$|Rja~V>{h^(%QM&F3GoFpAk}0V zJcT~0L*2)UQN6l3*r4Off3nA&uLJ7E4S&ulOtfRjL zP#CDx+`%e5EK@e`9YcS}b~UPJDJ&=j#UErHZPPE@lx&ejbScX;?dp0Qoz;;!X~1>; zqp)96F9r*hN7X1rM z&D!-$df{!vCRASj{K#Oho)c=C-Z?!r#$S<({Bk^?J6(AEO*XaRX#Vx-*!;z+gdv_L zd9u>pM3gC^wA2XIP%rJivUR!+t@IIp4{sF6t}I z_{TYQMW#Jt zsS6JuGw{q6KJV=HSBfNT`MYL2bgGBE9;t6Va*L+?zu_mi6|%Lut@M67Y+{IiD}Y~Q z?rmkWe%?D^h`|0uU{lGs{9Fu$1X-$Z)qJ?ba9>sPXGM z0?J(;J93F4>?Bc$Xa^<{*A=^a&fawr(?Xq9u*XG{Vx_Q@6J^6{ZPICp&mvlu9^J&2 zMLpzFa6ZpICbu#08;J@>~}N%vnw<`{tAJ9;}`PZs;<@s3r5$rOAXv&BooUJmzgydHU5 zE5BjwQ~8NDJY_+I0T4Rw=N#gNn;^D9>2l&%*tHqsGGz=pG-ulIBU(iJjVmxAhgZfe zjjIyBNM7>xuv#eJ*DGrf1`gR6d#Z|2Bsj#D=qD+jA10|XEInwh6-Dm;5Y?XeQVS{r%Nj1`tjC$Sj2o!WH{PKx7O7~;YH~)ySzCSo zA8Z)@mkk#t^+*Rt-2nyjx(m(5jRdM;K~i1tFy>+T;4{7i)=>$Vr95ee0w$82@MOQ~@}@q-?bhHIwciSG1I2fnu$d zl`Dd7LE7{$QICN_=#Op&jHg`hWqR>H%aOcSqK5rpooxY?L}j{xo`&HEYi{rP56DNP zq7sSNC|7TM3TWh?q3ZSC!ZteuPvbxsWrTO-HHGo1L9>&&nk|F}qmO25TKR$&BAMoo zu!~yHW_P|vs_&mc+Zt`eUh9C<`86qgiVoaXC&~8p)k!HE&$2#5xRsjE168lgG%xj% z*1ALpl`x<;cz_?!G+8q3PfXJY0{btOb2K*`EY^FUss}tO#IaxhxvXAG)T709S0lqm z1~*_^?d8D|*HX3iV<0HMN8KO@^%kE2f zHMnLClG!~Da9&rc-p~lPM8~m#Ha)~I2-J^qVc)4bgJfk(LU!xXKqV)_#UbFdWm9x` z0A8;6*8jt@=zSK$+nkXn`V>r+%eE-b|vXDe?2Yo+}LO^O7K;sdpBZspfqtk=B$2Syg`) zRcpcDf&K!h(w{YB@+;tx3IEe8xD_lu^j0^@viT@l=zQ*AZaaROS$ujB5(Au7_Y$wl zuo!&}aA7l*60WTP%>?mZ6rlQVeh7ItJKzKtCo)H{@z)?~@4aNSC>Gw$4`oU5595!e zRJtBc-@l~X3zRzBhPfstQc0C+_cYGb;jS71^@pmTuvolfcIw|Xv0uM3ZFP{%#i-&DuXK%dNI_~31@siYmxz4pH;&BQp{@9U#KIASz9k@z`hO$yOI@G zW)Nh5Er8le!t4hSb&~2}x|Neji~QJV-8<7#A%+cIt`F34$=Tm|-m=H^_ z#f}?(t*Jl7M0~-MnNj>MwAjn0*im9r)aZ`J2tccN(C$5Zg7iDS z*1bqCg`5Zj?d!h38@FX<1#j{UK6Ig2XN^nr>}zu7F4l}sB4@}dZDo$Bok-70NGR`& z&ze!T@;kG2dt*2Fq=W=i$C_26BJYzTbyCCAa=#4ud=sqq)P=8&%wCmOuxW0(lB?bG zJE=%#dTjR>1b-rHpw((GqVYOeu0L;c{`2y^^aHMf&}iaLs?8PBfX>vO%Xm?CQobmi zjG+z4(^7MctHbb~IZR7eq>$NK&*xhgtDQnBSMC%5U*R-}*Kdr&=aeYC<}Uou0i;(( zM{o8dfy#{wpmTxiafJT5Reu@{6Ti|H|6PmOhPcVdk3k&PcOFX|^zn#`oQ4MG1vzLV zyC-tozAR~H%2v^s_9d(@`WuXpvd`gMHi-{;HgC!qB0tO`1|A*hMnd!kZ?e_}rD*3{ zR9=-Jl~1-JZAW_@<>D>Jv#<{*rRgT8DmThj&3NsDr!l9fmzL8j+nASi>RW*<%^Q>8 z)7Mj`D5JhY3LwydBV90yIrHv=Wm&91@miw)RWa%SzuWuGz@p93~ZOSLDRJ_`_|a%Mb?Pi^}ni<13H;T#|p! zU@1IZJfnntsfB29(s35zkHeFFPi+3_;~!PgKRVO;tNp~I>B200cXFyU@$EQ~V)V5A z%jw=C%lN?^=Gfb2qTSIrxbK#qe`Q+i0q_3P|I7k-)j1GG&4#7NLC$a-15PacjR|=+&lV@hOopQ%YxZ85 z%aibTijUpJw`zquuYY{Kgci-&=zLeJ1>V)0|^QiN9p_cxY$% zbh}u^{)w@Wr|s{(z1+=8p;q+%yfw9$h~G#^E`BUoMypzHe{eSN;ZCV_Y?hE-bH0us z>F^qV63Y^~IbhQrOKOfvwG$np+0;+B^HjX`aCidzKPj&3{J?4fuuqfC%Vw^cU%Hi; zP2o?hxv04N?bDCBeJrW-JAVvse}DoP+k$oWuObnuvI*qj&&RsrsI6(n6qKChN|=}( z=LuJ}K5wp{4*pNF+lq}xY{Cuscz_eaT>V3oZex4(vcsOU%fo_&(xMXnyRGOrrLBxq zlXMcbZp}`gO}Xt_d87R}^9&`k{{n$d@+_&hD$5Luec_psp`AxnI%RlID!o!a3gX_5 z0w1voWRtqZ6k`v5+owpxxWomevx>y%E6)S;M*Q|_wZ**^FFte=X7r(dAUX6UBE zH(_TCid8|&{^M)nrFK`z=#kx?i_8XvLn{8N{@ah+U;NYc&+pZKAUx>stbhK#NM&@K0a=x1DP#TDYie|*zLY`Qah zTinCVHL!|JE*UOTa*)|aW`KIfkq@Ub`_w@Zq;9bfx%4XbTI0SfEKqZIC9dnwFxQZ|lH?bmWX#?Z2?*Evh_Z_go4T2NfE(az)vtcEEnv?}kncxauV!;I|H z^|c^CtU)RlRSq{#6i6M!_01`P;3O>+!o#{8 zVZcFW4zsx#j_~xK*3hbf^!3ig{pe$)*HvjX1T)sDQ~-31pd4U;i>cozsYHjpa17v; zMC5m-wq0aH8XIj#t%IAKh$-})FKn%NZR0%WB zDfM=XJ{}ERte?luzSB|#mS0&dSH3Qilg?b|p%R?Cv0iHzS+A6rkx-94+L*lhiEO5y z-^6o&5-hDO{A5&!7Wz31q7f>gl$_6u`8Ba5$XBvtb3{0bxm@NiFYJ05i)bwDeFEIE={vY=Kr2~G}EE6CNJ)2iUrzXeN zeMvX#7;boJqAfl)xt$;CmY-){5P}J#4_eZ5=uig$M-D6LeTjeN%?D)43KP{G*T<%}y#NSxlwn7c6lDF7R2mr@6qIRUs5EKOufw)-)V~fGhpGwVhcP$nq3Zn^6|)aZ zVf}t5&+F>AV{2Ut47m<7RO;>F`#&=0))Zr!AKj3XXe@<<)R*tcOoCetbieUf}XY^>g2=-)E6NZh0)oo}O_K zr%h|xa5AccHT!xrSAM6q@}?Urd7+t5n=9bik23K23=15A*$)3qX^xF~b`Zm;68WA@ z#~*@vqTd{6HJ*aloKvbRD~eS%4`85a#$<0DcH)N*k<}nq2@0=XW^w8^O zSQc|<6K&VlHS(iy;DsXsp5g-GeP8_m_qQigJm6-x!>Q(W4?zUjX2VTI2`B4dAwyY{ zk06YXSx3r_m3;jAvqPSILt{fgq7)C~p=;(@3B4wxSnp#^>5~S=Gra^XIyFzCEd0n&APHx}}({!I_&+s)eF?QD2nEW4JK{ zHZ{XhFGDJ*L-k1c-I97>J3RUy&D@^2?f0Y!DCXzbNUy4*&@eeWajD55&+^%ut(x#VFAa%@nb$R*GzS-OWH@g-Tl2^D*72*2Tlwp_ zdCuN>Qd?&gUR`!XTnaqBlC?A0+Oo2M%nlZ1h`XGc&Tg{Tt*F|Y0A0)4{E9B-}av`t8w(dJz z)X8#PfDlX{k+{`2sM`j14lGP|z_?w1%9j{txq2d~e&wEce{GT<_hn&Z&cUCBnO)2H zRW}bo89O-C3@9Evg0&W}ihJO1s2OHyZ+RaAAs%1s0?Cyjx_rQDfime z%Xm#@H4J=SrIWXU~orb+Th_mHZKmEw6vD_sTPMp3#5lrO#abw z9>~TiS_=2DW?~=4+exYv6yQ~-iq+GfwI5y9R*`U7H}rn5KMwh0kwhFkYQbwofS*w+?;Gi*3c{7MfP zR~aTVGy2S8-~_{A;@_}6ADK;ow;RLCUJ9R{ z_sIEMuy}EVGmgG_*kw7!cN5z8E5DcZI_-NZaV>GN_q`HnU4p*ZYld2wuf;W}@-$%- zqw0$KLkE<_HJb6yW@Cq?_cZ=c50jHQ)2uq_{rDvKu(YCmUGUZgTKHy_Knw{W{qSHZ zrZNb{)6hnkB|a&^XS3q4Y!c}Y7oG7+d`bF)@9zj(M>)7Fm5$+}CC7UgpGfLy9jkwo zIy5yhm#GdqXwuEv?4{dYO&Qp7&3^LahTaSUm3&}t%mw)np*JQ4FA_0O=ZuY+^n(e% zv&$L52h1mrmrKQG@b|KU-L;+B_iy*BhYtn!-2+*V)%01d8+k}`d$TI51{FT)4cN}f z*j#q7Y96jN z&7mtM&1#bZn74j)m~Nz*sV$%ExgvJaTbHl$uAYxPf4p|okjDVd=~c zeL#_#?H8X_cWL%(*Hz@Y ziaIGmjhNWj?fEgii31&fV58Ypo!}3PtUe4a_sG7#ud>z4#$Z@8B9O!xHU`TQRscDO zQ^ziU8bI{s$O$t&7pSHhl=UIt;_ugd-s>Iap*M8yN-M62Gd>I-6}IJRj+q;(9F*%i3vc<20hL*$)GXmP&{oy$w= zMX`C{way*BBS#GOX^Xuj>TCgGPi z=Jc;?g3?1p=x+mUxIQOl=U5heQ;Jnmna%YEJ`VkgIiavto!gh{eBM@M-G+QW0Q*o> z5I=HcpXV6-9AinXfonZGSs`JZR3-S6J_0Z=oo+T(5S&eX^xg|^-<@cd__c`@I&1dC zesgzpH#m?9`}eoOe*l6RKooQS|3SJvDUm{8szkQ^}{J0erA! z9%(+&r#IWlcB9@RB{%nASt$Xy+7y$P1YK;ogRz0za{Z*$lwQI7rdY^YopdHjrhO}L zA6#@mn-hHPvb1ks)}aKWwmjW1Zf$WwQn&!0q4iJTf(4E-=d-o50FtNvugh3}(C_>O z`d2lpN8Qt4!bSlWtaQZM*h@j)Zy8PqLO3XqH>YiWUOHhGF=)Rpfv~NQ4-K;J@l3;( zxuqJw?@KA*(th605{B~zfC4L%rG5I-`2h)NcyC&&@w&Ha!8_fFkf&v>VUoD!b(zQqqH4L;r%b zNPPFf(11mIkQ5E62%N?nlzxtCFx&zXR6`njd|&+Nh7(CqkD?MfI8P3cwstm_I@wbw$j{Ih?7t2sj*WWW5e~(!r$?ZlscRC` z3yfM!DDGs-g?utdQtnL8?P@94$Lz1?)JoqRD<@N*4wSw?q)U7DgvIme5BQx1fuJRV z%dcRf&j|f>XZN({!4_#MH)6A_HDfyD_rpPtCGu9u56u&>86Ms`{-cdeX-4gSk>fJT zS$&oCVsqdosPv4L_hw_$K-@Upo9V`Y#}b((R?pl`-Jq{$a+|?InVW>NAUbfpMDWfF zM6D_Eqt*nN7Um-$dAyouQP9haa`CV-0|#Y%C#CS_;!XcV8JuHAxFkmp7plL6jYi_-iw!W?YKoR%~jt~NgKwe z@zZe@jKV?Nn4D%VI@rBG2LC7=pf$Lw8q6i%Z2l~}p-3;EmUe1t5zCzU*rV5YdirQJ z-1KTEWVX9-Ry4mB!A7=Xtx+A7z}GS_x4xR4Q+s^xZQ8h`AfT#AL^-l>O#L4KA0NVGbS$`Z6 zhVP!9$K#U@>YLl#SII_x6-h?Mb9a|rdslL`r^pcVf9U$kxTw3WZKR|Wl$Mh25EwcH zX@(Bz?(Qy0C8c3#=|;L!VqgI2?v`fgd`Itdp5ODF*R#Ln} zLYeL*Rx`JenUU4h4*Thk4XfrxyU^J^+0ZHTU4~V>n0wFXW?et7_#ATg&!1(NGskaw zZyY_r2{w|nC)+QzFSztHZi8&{)+}to5#o`_iDe-iHOeUyo(`ob5sZkyEo-zteod8Q zmp`u+>p+!yA{0L`tu-Y&rF75#DG$U}q;wok5rcocoTZsKZT2)J^!al2?pXJy(BBl; zov7a%8-PDR^mMQA8-ut%Nh{xzQ9s-sOo0S4!r{IsqbM9%kRS9j-WKh(5y&K@6%;g> zt3xjyB1MZ~=Xk=~k{2-ag0^0EBUHG>bDwDCrc!j`o|WZ+zc=C20cr?VCeT9ZT_ZjIqDrj?1Y4_vpbKql z>k>1iM0QO}^jggf$RGB$v=BAlk$VM(sz7a-mygAXPH7T1AUHI#$p2siDBEyJv@- z3yey83U=j9de^PJz1}UhMjIMFY&kB!{_#5XqOAdQ6#KvfdFuVP^){Eh2)E8-tTpE; zjn8@S{*%EiLR(#F$qCc@ILNWjzV>iX!wQjK>uYsn`U;RGGkxTevNP2_^9Xzb6LE7# zd}?=K*g!nEdu$^gg#H))T$2q0RzkhjVbN|Gb?+_@dLZk2ddXeyLGs>su-h$p`eT0_ z*15JlTg{|>o3S*18y~-dzd7EKJD0xEf1cRi-=ndLLcdJ`e|Ts4W+;)>f8*iF1V_T( z#_UP)DEB_Ko#%5OhX`Q$iP4B|Ysn)gCf%v`o-ky(jU}bfJ!%oyj=MDY$n$UB$%=Qe143*ph9+@-sFr{s za~D~KKUz4@ocHH<)&=_(CRR4yEC)YkegE!1?C~0s)&}sediv$IV)(ZSvwrH<*Zz&J zDVm_=_5k6VuBeW}7~w|}u>C>XfuwwO)5mBBca9n)^Yqv5D#xB^!)a-)$0>H>fuO|% zGny+uz)qKG^+pDh-H2~}v)+UUd(BV#-jMyQ<6MVw21tmpXB^cWlLpP;VVvf?i#1cR zwR_80q5l0+hljvo`|-+zn|G0ue|m(&mZM=4IDhEM4@Wp0v(L;j_q*L~M?cJ|a%;D?$jntbc7?gDRb%1EK*UB3##rKa2ys?USI zmnyEqC*uxt<%f4cm^A!BMRtIEhvNNOv+dzi@451{j$|XldzcCIppE*=mlW|nUpBow z;a$^RXmLNj)lVK5py%FpX!Q80w8BDVU)biYFcE$s@2+r|iPH1EkS2wTMLO-Sa#^r^ z?Qia!6-)GY%RCl#_m+#gtEWTlthT#ofqQQDC!fn!poz%4-fWufY^=qNLBywoK<{cp zkr23?UP|?1RO%A6loE@TU+a}gRdONktOLt_u@~R!GcGKA;Lpt4h>|Vc-YaqUQz5nE zbo8MN{#G#^H>2`EDwB zk|iHq0W0DQyS*h;ULmVtsfcGQvka7I>a6GZUn`Xr(eveE72s=z;~NVr*A&I5A76B0~PeF7#hRE7!}czBh+=D=rhqE$4l65D@Ab{SRD|ptSI$ zIMzww!4evmB2J4-A~$ix5M1u!tCSqoSoC~8QZLRG@uxbq18+40b<50zO9kQ-&ZIr7 z&u%@qdgwiCQ_sgs^4f+PLe${jOsS*!n0#&LF?Kh95 zLR0<~34SUx!cISjO^#?2MnpZLX9nb|VPs=4uZe5X1pSt$?h77NlS*Ml$ZNEQIHEE? zEzNUOu%hV^k4gbOI?d&P3y^&C&6DAlE}ECSe_7DN&`tbGG#$e1!NxHIimT8*u`DDB zslC(~SE$=6ShxaSZ8@!ACN#laA+R7jH>K4#bkimaf3eOz9b^L}7iLW3aMo@{jV|Ar zT>%p|m6T_!lfLrFl4ts?P;-RjV(K|2B>>6KTatrP#{)-mh&kd}OZGa*YM%IW4>c$J ze{*clM4RQbh_@C$MidHGN8 z`ipU|tmfRpig)_mu3)?=h>A+Q`~y7xUD2+J@6}I;E-3_8$MC=|xfM#Zy;gv^K-6(v z=kL2w+HsPRsNIZvF|GYbm0hx}wHD%bby@HG=$w_TOe_~a{ow>x)(>W(p zy(|JYb$7+2zs#eW!{TV-fC~#@9~4mySMY7A`x=c@q6G6|B|}!7 zG#xyv=dc}jFHyk>q8;9J62<$s7ayU1myrTsrgLv00ar8f%hEl*n%T+%9E=7A7kXgp-Ek33b$D6wwLzuE%)#k+4ANZFrhu=V zcrI0pYKAFX4<2P%XlXds$$tFHa>3z?|HD!B%=8~`ulx?DCGGD=S)MAXTTX#Nnl}l{ zfJ|P>tNGZTC2*3?uA@w!HZd11CTABCmc{_>(PPtjsvS zBhCbK(z7hba2|H(3oRY$#)B3GV$3!GW|jn3(JTA>oPU|~D)sx{45yR#x3F-q({X=s z^^{8Q`=MGtAu0Zs3b)x4R;H1I%0R(Mx)<45xI-l9XO1#d_BzS-h8MkU=D@Jm))*Jy zdiMwvm?>-lFF_!QIwEM>%TiqIH;J2mDsPsg$Zc95Z(Af)ldSo1x+*2#FevGqGXx8 zcKOR$>`a4KLFrUYlTus`4YFMmFEsD0h(L_PK|@3h^~+?7o*$K9Cm=|GWl%1(rhaQ} zxbgwKYTxeePuy>*hQ|$Qag9foadZ>TAE|z36duvff<%&`HN4JNR0hia_O07*Gq4Pe z9Jss{m?mTOIu8$e6o^Rj;*M7M#loJ>asFxn=xwRv3cU2PiSU@b9OyuR(BGG!?7_k1 zUBzFkOS{Q`R~fEiEZ$5vfV5W{{oUQ??~k1x5aI!GK=v%*7A(GvIfr1;s?G_dIbxFU zPak^~ZD7D>8MoKIDU+y9K;al<)TtPXe?{lqO4GPc3`@)5LtM^-L5kI+SDGj_WXaOf zaVBWnW?jST0-VXBXlY#&!>5C*50~pOvhj^PdI)`|i}F_`M1S;P(6B zd%xYlK!DIz=$G=Tv=(Mok#1t?iUP($;+1eTGs(@%C_Mw^prA_!N$ zPAue@kKvgApm4cKi%2pTkU%w|C_@z9oU~|96GEhmLBt=^&)DG75zC2gxUft!zCn%G z%=y{Tb$GID5R~I;DFjdNK~rV_q|xhq5h(!6HI{vc8M?$_JE(6p#Mw^Om82oQ@ROcQTkhnvD$N5{6Oxy`c{2gRw zx?jg16GSSMevLOH>AE%$>27dKE*<3`?SRJOzcnn+W1f4+ACU-fxKAX7!Qvd5|F6=V zrKa_1Te@2vph9Gv@~FAJ5HbUY#V81**xc?b8;(m3tfoe!Y3ppIa45#XO9T+J2nlKV z@GQWXJmyyhMDBdcUa!KIHf!bh8z4cXFWyEic^k#vvf!it-&|NH@#8hD*QEhreSLR^ zJMMG(V5*f^n~z5<3$kInTN~x!M zssj~fM5P*6uq9jaQwTZ*Mt>$UX1OnxT(kG`;a}_(j^*ti+%BKivUh2FH|Fk-RXDur z$mVTR&>v?~?XwNnB36b29K}PW-Kdc59|$ z-F$cD%O)Y&q4L=)8w2&GBApE|jt$=CCRm_}+C7<;qlRt*gw#UJWCJQpi>vUx$hx1K z-}gU1k~(h1&TBLG;F{@0>FQE`I0+NBQEv$={l2e`12R0B9f06`D~-pY=H^iF#uP2_ zc$5WXZpX|(ppgO>nE7WQAsAy|CzqAn3e3U_69MS-xoMWaVZ~c+(NU|bE6vpwbk2oxo9JUOP8Qjsl zn;N#KrL>@EKx4fb69FsT%Yj8Q*(9A`I{iTiP9hw7*st02RS0_9tTIDH?Z&Ag zl%<3bKM>lc&f-*Lb_YkEPcf=#1~a%YZCx~_X1 z+kQ1)+M5=HniC|r`{RISH5P2G*UQx=`$AH9+{)N2jI(qaoO8?2SNma?s;4^YQ!WqI zWo!!RqhBiaYQi`JiATEVtVGNeM97tDux*w5{lo)fBa*nD=`p6NNn9=D! zg-yHp<;xDgSubvkq(%41o9H~MWIjqd?nbGi*TvICg$>7&&NIiOFwBLe@4act1h zGC&gYm`VG-S&W>Qi5bfRrsH5(8xwUxQ3=C@zG1eFgNjmdo)NGTC8!9MgaMHCP~Vo2 z9X_6z%1;`;kV!a!)**z`9eV~nU%49WEPE9FdaTdNtssMj2RwUgxg@BYJZ{bh9qt<0 zV)Tc$-EC+$9CMqG6OBZ-2sSCmohjHB<+*Y%oFu3N-vk>Nvb?9yzqvc7N65UrNKV}u zSo=4Mg-O$y1u)j1z5VJyf5IhVX5X-V)W~7N;Acxr`0IbU!pep=0&?KcR~r83)_03L z`3qNivvsiK-Tya!Vg2s^QbpzeP<2s@4Jn%g+(Zw#Q-QZKd6zZ^+x#5v84VUjvHSNev`T{qj+6L-@b0#!ljv7#>vl(JU>6bf)gmDhq$l+6ZI!x2G8AbsW;IF z_E@jCgHij9_oOeETU6LX4!rN-`zNxZ!H6Uw$_C1E8mcTVB~;j|s;X#)42P1_>sU0$ z^Wo->Fwxwj0Fx1kdn>R^rVrrD*{jig1G+v3lxihX>Bdg_USqd7`^Wzk(mNpjW9wZ^ z*z>0!@1aqfE(GJejBFYFHd?ms5$ z`#+2*BJ#l64xdlg%P#yboml-~S!Wx{NPZ1^-a*w6%QWX#l-XhfGXymOW7#6GIHe~& z27}7xoMKZCAOa!L>IX=%q~aS5&LyR};`e2dH4pLKgqgC2a?}Q|n>@4r<|WC3m`lAz z=N*BCza{c?-Ut6~;hbKu&>*+4KqFmhoo4BJ6?-!YB&Espk@O}+uc*bJ-*h|4-TKk4 zlkhoICtLn|gDI!}s{>=)H3GmhQ8jcH-+cY9FvR+#8B+=!k|@y8D- zO^{(7vx>JhvS#&rh9_3U7?nC>`x}}DM48q{1rI}w%pEwDy;E#&FqtAh$S`Kw*c?T* z0R0XE`*lo|MPS~oGoCq@^!5*ECtN(Nw{wq?C_QpCl3A*NxCv0l zgSYAzgOfoHdCGG1d9>Qfuew!GgwKFvz0wR`tf8;B3W|%ynts@1DAB3O$?b6`ES@S4 zs270``KG;5!uq6ZBp`BTG_WL={#AwdaGHa0gSXGkg|L_V`Swg}F$T1|399*ar5>og zP~d$f%Ks(Tq<&4;9h^ICWPR|~qxk$j47ePpP5!osCbw8M-dhjG&S8}@uV6}*>U<0a zW<*cS2#`FOz7VIo79+o^sBFyvH3K3WvRd#jf=*^KMVvv~YhZbG{T%i9ZwN2|;h+6Z zIy|K80$v?40KUsDgXk?NCMtmCRNz{+AC`n*I9FplQ8e}(YFG;KtK6Drz0eu8IX0ha zb+G+n(n^)+F~D2nU4h!7)ee@+Ca!NY#7sm!jHQG&-jZ%2G|gUXr!wV!zHeinaU$^; z<9K<)y<%s2+547c30438w30LYjm%+ai@w|3?ACV=vV51@D>s&4F#hO9@w#7SNim8c z1CkiPS2BT9LkFj(O96n*yq&Y|Y{AA85tn~*dGMil1Om!q{P^;*ZISM;l*Ktzm<}eU z^4K=mkBVYI1c3YveYzE2s1FD0D23l`_idsZKTE>uR#8&H9dj^idKvO1XeL_;Jhf)HWMt~1k8NtmkFdV^vq_jRX)KdoWpRynWP*i5d6_Zi^CEx&6%gx?0C$96_L>NL&W;J zo{WfOp?Xd5&te8x1q_lVu4qc@5E6GH6KTyJgJ@|p`fC@?gRFZ_o5F_~?N=s%Bki}~ zv;*4tVrlG}qf>;4kNS>#a+Y@o?X!O|aX1#Ce*u?=J4w-$P)87quNAk*0q0}E!wH#o zoua#u<~^^_y5Mm|xNy4!b7m#vV9I&;iiaS#m1pL11`a|fduyX)>IR@oceLb>G}WwF zJ-Avho^o_arD2VC#QBxer8mmNlB0GHnu9tsL+0N+@9me)(GbKV2;Cbrxc1;_r?Tc= z?4A|m0x!@o$aI;TBWIlZ3B)sGK8eanL7$1{gG4!%b(L81Xjxtg4~DVf&KLc|$RmPM zTlsUZX2X9LLs34TT?Pg#tDRh*cPT6*?z;uHovkTkE?oRAkR^rviQ^M4clAqCx-iH> zwdbg{S4PyFR)+?XnioX*{7#>3e2-z9UJY?+vItbDXaq2xMwG%0WN?xkwbGA}Nfk@Q zmRb8)v--^s?Jo8(cUxaDyhpAVla_+L4)l3dMqOzJuK6I5 z74oeR1KJm8IckLyp?`GsKiu3Z4CG)2PJoP zJQ|p4x5Cx5P#0Cl70fdHa$dFI%cdb~84cOGi&iXBucK9U%s)IGV5@qrG|@5zfgUhy zm)&UTzlv5uAs;;YNZ&}OdahTqsHk;x@25fk_CV^Z+-ygG=&yNM<>pr=m2|*L|E`n8VFvgJl(lAwu>sP#PbNo z(2Lx$hhkr(F!=8uk+G@a`_~1Zlu|E_x~8f=1PPWIVajcTv+f~e?#^?w!z|PxAB}d> zNDt?yPH(TDCrbv=<~GV6)jahft=E%DUczdnHrDXE}i+ET~qm4}v_w|{kPvVUr}15D?Z>#rWiD&?QsS_}&`<*QeGNu`Mk zKYd3_+)l8DoUf5(Q>9RRLCrMmY>1V=T`Ky8!HOY}VKC=ITR(XANdpe#QYj914a zQnJS|t&L{)1_O^&DF5FG^?%4ZX1BwE*2-5Z+xw}Y2e`r=Ki$80wSb8(GAx+Fl3qQG zxk+Y2dT;1I!)dFy8_1bxvtGS~v_+H5$|F>X^EU29Ha+6prj<;WAixUVbrAOf0q`r3 zz-vZBZdY+^UC@Pp>*u$4)kU)2hXE4SNhb3cbWxsbSh^9iXdH)3zgyzIWK9p$i+J4q zwO{$@0G038g}#!`oUg@;mc@ zJjyU0jvB;(I~9DMAezD+-)A&CvEG?Ku}-ok7zRtgW==I0#1!~5<@6Vpd*LH0{+`y3kgS{%OW$j=3!hY}R_vl0++R<_B)ad(nkG-bpB6LSU; zi9@q0fdy$o&i$!u?nG;GnQVFPc6u%N&4lt1sX$wh_vT2$a#Yscal7F1io-Mn~rY2YZAvVu9<>gF;K z4p%Hn^lKhS7IF2nW}n$&XQ%5E8*vM%tfj%UU}OQmJC$ujgY93Ipz~kRY)){A|2nKC z#1VuTlcAh!0(0pA?ul=3k*1sQCjG5%pCR^gZ7XQV%#N~8hN!DeCbZA*s1#>f4lI5K z%uvIMJ`W6=8o~w3P=vt({YT(S_zGZkY#f(K^N|d9_xb6?+7q|#L)RAT%i20u#g;l# zSyY}6nA_}7GT$pl;))kx^m9cjT8ZPKIDKaHC21LZ#T-b|&fn$xf81bi=VnJ|DZYwM zN-ZqHk#QGMMFP4$ShJvw!fLmy@$_Xeq5ZPZe~PBiJo$AOz0;2= z8x)1LQVa2Hx$oH&21PUpMeGJ8W^LwVs&Q>BA=YZx@~Y4@8Tteo6T2mGL#sFl-qjJ< zd`&iNhG6*e*ghGVrhL$KDtD<0r^3IPbb2N%t75s?w*mIRh12_ynK5f0PZy5pJhfEY zU>x-Xhz`9}T*xloH(B&dXt)DagXpdzOKvfzf_O#zZ`j&VukoroZ`{wsW0%YihtVn= z2Dn@dF~ZE*OJaKzGbLrV9psT*U~aYGn^Brgo_$WDUxvXh4fHu8+_w?~;>8s)?Z%W2(IH178sMzQsR-WXmLwIPz?f%T3NlL| zAu+c(_Vpg>S#lR}>)}c0cWu&_J1RA!2Wh9|Q@GZ|8EZ;rqRlYp-)!K1k`mASzxDG~}RVU)6sVg!;;h7JH%fe0R^afaRY?s6YIz@0Ww)tKZz* zVg6|4Y8aCf9qHe$Kfaq&!)mzAwMO?Nh>0L40fWuI6-QMS+eM|Mc#xH+=4Vj1SSquR z-fP0@XQD9TuxQm?DKjGsEfqVeqwHs_Z(Lu#!6*Cv?^=L5WjQ&inThi?yEBtJ5x#Bq zkhk&AlfNZ!>9|pXg_FPOVjdRm!!%fSOK*RSJ zm*XXjV&Y&GcKQ+c@JxyTaGU?vF&(~1$5hV`^lZg6ObqyZYH_FxUT!eRt3}+ssjEF^ zE=MpKVLdCq^Pw26ORni{Y1|(b3db0A$J(fg2BW`1GKNa}P{KIQ$MJ7fcdMqNf8@qE zpDItf&!*X5XswqfL9mG6Zup%H^LY_U20d~YBxWz)Ax&9IL4vMc>3cnBspw`~JJb9z zQrSmUoKESq*(AbhoA>S{d(jW)t_Y2qKTS~JbrI|`sicS*`2gq3vmut2wG4Y$*Rl5V z=l<=FO+zcBf9h~Ht*nPiZpHnN>Ywzkd&w%fPYW%FY2Av^g8=zPrT2QjYxw_jc!>PVMCuzb;ZtBJQ2I=POaW*8#}YrdfSu%} z<5^o)Zqh4E2PKNL|PGfLl0kr3C3g=U9FonO#j(k@LgwpZJRCM@uNO6jivQM?G|~3ui@;= zG})*I?r+U6#EHP;Yy*LlV9^sr=zV|qc`nIAww!xE7sy> zugeb|xhHS#sbG;Pua7vMWxE@-^*fP@sja-b;|pH-mK*2HdTOcC{HR2dynW;$%@f=-V|*4j_oR@IrI0dyfo( zxLhGohD&*Txi(r9aN8D5Iib*G5D9C_gi~!2X9pZ2o@iwyTCDs8f_cDTzyFb>E?MX= z$K+x1kR0PQ|I(qb0!JFHoEsB{x&7coV%Gf9!syV|-%x(1=AV4UYHg~Zv|dwbOI+Ss zJ~&zj8(grxoNDc4_fX6=Qw(FX|6pC@C#Iu~Z+Q$R2nB;H?Z3sSUj2W>sJLce$zvBO zAjh&7WH;@LQUmLZlK|No3j#RlbL^E+d#mX-K3Wn@Z`svg6d$%_wWU@Gz{DC@1##P{ zep|hx>9}BPEb^&S6{q?*TRzUzu)MSEL-&Q#m7z>TPiGm$E=;K!MN%S}hR_P*S|^b= zkPWZD#(enj#YkLEiG~>5vknVEzq7KZMn{sci%XXxwU}I<_J1>J;(ujmj#m6%Z{{7^ z-i=?E@@H~7%9aoDCA+gwOZR?G!hWeAc@m&proc5@IC=XzX}gK-vocgIM+_H$D}uYl zmbz>lS~7*_moadmsb$yOZjMkhC~p3Q5wh9^fxznbXSb4d-6%t=c+a=bk{?g>D`AhE z_&e;3WSmg_0P8l>jowB4ShBNi6Tbg7lH3=waGKxOBPZ89sBBMO#qIC9tM7=t#&+^|I^rE-vJX6e;l3V0(#io{dMQY30;j5BvdC2*c~TaZyo0c z*)I8}xN(7|jS$d!3V@+6{kUiIY_%&er0UIP@{-R)kZ!~3%m$<7Vb>LRsMkKT|7(xB zI@6ukd>0f8%k>I&`q76}(V`pH z@$9~M=F+%^iU4xKe5?Xs;dg?|N#WpPCVXU9rR68qP#uSfnm04&W9Ko{=Z%ehh5Si0 zG3GmqjXC`v`*pnkvJ9Qr|0J-uIFj?co88a13~MN7#Nr@{kZzZ!6h^_`PBn6thae0Y z!*{skf@@_?EHWbEbJMc><$JjfjT9I~9=g@y0O;hTYH%Afm9a|LyK1%;Oq_&t4jMA5b478BKrbGPcyZR-x+_Vcd(f8)A(@9{S)=}(_(+}+z8O*}JK{5zh$3;>n$sD2fz z{wg^FfqsS2k4%W#_Hf}5jPx2k>v1+tK_Uu$ao6p=*YNfi?sYdoq<7V98$Rn5Qi6{m zX6wX!>Ymkz%arPj>XmA#nsTwmYif3~_4E!_iW_2xVsHvo{;lsDE9q2$SG4kC5(RGt z>?1yZ9@w|EAE3AC^ZRuja5VM>#lx}DS7Zer=`Eb_wzQmanr)*qzeioJo1MAI8P(dd z4XpdZ9lW>b1_GCX5snR7TG@O=&r(w$T z?hwQv1i?b{w|Ie2!q43qkIP#M+`{67z6<>C(!bz$64sLJmUu-qIeX&Pht_?)F!uAe z<}{{i*svabHuSdhf|=keFAC2Pkidfg8P9AMSu1=-Y+|lnnJS% zx@0C?*opnJ-1;F!O3Q+aMuMG_NTo$C!h~o}pL;vP{WCc`ZnW zZFEB~L2KuE3|Y)WEAc`~v;TKh!BNo0_w9+d+K`OBp9Ex;&z4BGksDtP3_l3GW~IHd z9vcrq*WG{SP*#S;$S6eG+7=3H1kuWHnMq4)*TlRee!air4sAS@bKlL-6TJ$Ys0?Om zF^jf#&@(&N93t=;<%QPn@SWjR#)9^LicTGDY(A()7OQa=SY>JsXHWVFik9%!Gq~<6 zb(QlFRmRNvkdkWan6rYlA=nusIA%^7Xz??YQJ~6XSR35_yt2g@^A>y3-VIiiGR~!h z&;NC_7T`F(e)Z}VFY3|3@Ogt21{{V6U#G{U0-2A&8?`XEg)-DRRj~-G9=YjlO3So> zpQs_>zkqAlJf#Ze!;1@Lh7q=BQz$Pz(>%FIjFD&E5Pv)?jA!Gk7Rz+L70=bZ=o}h7 zn9IL{U3qstZiRNZR5feq0T>y7{3=@d@Qy*u&M7UB}9 zEAa6=B0@rseTap9O~+|SN|w!;xRo-DzExvlt!hTLG@R{b^TEUFRlYT#K)kV7e%c5q zuILlaBIto~{3~cJ=wMmP0X~Ng@CHuM@TP z_1HK!b5G}CX~0F9)qoQ*52u7VETgHS*=gW1Th{j$POz?rH}JgY_pqRZhtp`^Sj+H#QfMRdyYlC`y10-{b|TW1G(Rdg z3T{AHcYpCHA=(^_wG%MuO8SzN#8WT?d{==+(liqssT?u$^d%}))TJc!<4BF!TB)eI z>)jW%wF18Q9(he0((*G82~o+6?X5!jcUFF_%%bXT1qcOs$%HD8KBM=^3ij;(eTke3 zfRD)Eep?eU+4#oOh0;nKra^E}?E7cg&FQOeJ=lJ2B6_&&f~9|^1!wzzA&;-fN9@fYJ*4aydx)^}F9YlVF^~ZYfOpIbiN4(a>(2tuH@2_+rM+w2u51t+f4i{1 zkIhN?bt>2nvA@Xc@%4|~oMuL@VK)E8zv|G~LrIeHutwrVxWU^#w`u^(N${$Yq4Uv{ z2l4^p3$d47(g3HqCAzTIWpB(g3i8-pI6a+?w#ESSQM7T>Y~7qU_a9*|FNmrEz8C#^ z7awHBU-`Z5l)`*l>dR_6Q6;?m8?qP9lC*8S*Xz0HuinF>E;b_>n0jw&HqFyG)2geZ zl)*)mA|`#WB@xkVYT0?7?BI?Y-HU60q|vQ=w|=I*)~(?8CKDsvh9>Pru5(iqDJH`5 zC)bHiE_96%rISvkb8e2!=_ql~ccx*9YaOSNfZa|eN;nWNalkV@D&uzZhnI-q0@UM| z`U^>&lT_y}GQYYNri>}(*pLB!)ZB}sU`V;%O9Pkl3A``0{#kahKU^G*xV+LB(* zlEzQzijwD2XYXqVqc*z+ruD={Ow2j(wmz8%w7pkF>to}HxITDydR=Qq{@=~tJoaB6 zqKd>!QqtR(P4Ttb#LT)Fr;4W==nD?Pgj`y(1Tn^TCf>c z&nT`f4q3SEAlPvA>NB5vO)kelUL@sT`t$n>Md*5Br*MC-MiT$?Kan} zTX4?0Bs|QTT+O@<6VPNIVqFPeP|^x&vQe(n$&#CBqBSAtvh5d8xLM{Tzvhdi=UEzH zTh>NYe4Lv&l1p(|5t59Wrdm~mvwDI)`+Vo$3ICNQ>bqE80qlHaX}KyjGkgio&{NzI z_lz{*IXamDpJ;r|(NrvESUj)3BW$R-6kgeb>scEkI)o?SOhsDl{R}yia8{rkKZ|d9 zxeQk`b%L$(qo5_Yv1lre&*v}$Wq>iNC zV$dNlVGqhP*M1SHBUHh5bK?1e&=u6|pb#M?dN`?#STry8MGLv$O{KuIThy&~8tAEN zv{$qE$v542E2o4$l)MLp7;`t5gmC$*P-k)};-Pn`a@fNGqvUFHct(^%D4#KN@D0uhh^6g(3Tp_4@R!KV37fcQ0Jq zJjky1VX}GM;+`T0NNx%68wU7IV-jvd%Lq(+ z+bGCC$46 z%4M-Bs6Qj>C*cU@?y|cB_R&lBb)*GETQ8Z3IWxJuLMt(~#$s66252-_EjVA!rO58g z_M6z@B-8pDpv;1ZgkW62ua1P?nzNv{xdFqhEuE`h!hu%HZ2L3nmiTvQtGB2Gln{>4 zfOh~1bFXv^i`?zo)lagAQ~oSWnCpT4qqC7&kH;%y-=>CcdZ#^aqQd}XA%*$^FOt`J ziFD?xuKoQ6{nP!;-nsF_w}s4l{*Xr$wIBK9iedab=wtzFgmMS(OerSyg#e~zl4)_W zCV|O-z|dl#DGFE+@q@#rS!N*rdrT>b=AK1LYzs4RT$mzqdiB>ha}%`B1Ij3$z&#_r zfIKlcJGk)EIR-KY{D{uRjE`-|U5c4m$g-gKq;-ODQeUblyY>3P*j>3-D*U6yMA+^@ znydn$d2)>(&EdikVx~gM&M*i_h*b-%BmtC0Ca_t-@WP_QL9oW(gO6iH2x^Ko0mY_4(;H@yZNxwP!hCN5;bVWllde z_s16}s!y}+J85KgY1YXvZ}Z;SQs|a%i+D}#vr~8P!hPN}JugppdM_5*xu}Bn@)Cs` z5xr>#KmJbQ`^~|g`WwO2v>lowk58Bc?$MU6b!DRE%nF%1XoFBTy`X$;GBbT0KSqe& zB81D93nH+D7Q}`@Cj6L6j+7?s9{a#qo3Q;QRSqZfRhyH_yUZb`0!W5ttfz!WrXkI9 z0R?aklUEXx34K9iD$?5!6!atm2X}NhNC3_r%6oWb7~NVWWlAM)^b~dY0g2qs95@SC zfa}~GIh;Wpe)UYlr7@B9;?V0&*V%Cxue5ylGbA|2^%BzM0?9aRQHd`EoGOe@RLY7rroU)VgE2EGE)&7oB)8K+yPGNI9Yy`OuSR=BTVE#1<- zs$stGSX&g}s7^_t77(BzAdDf65B2S{>oeL$Uw;>nn23GHM)_Zk8SH2C2AdCF^YBv&orzy#I?^^kgY#LH~*9`bBc9#aaVF9i#- z6o1E~le~(O+GWg+<-+~u+p|yfo^K=bv~Ro^?D;m}2zJL+Kg5nMa_SmZ>}tz(ejQ_BwuDQ!Nc>V$*yS zD>h*eCr&}qMhe4u2-fN5ox2{!3ZIe2*!ZRxu+(tGraA2{ZfoSv^bCn@2_yWHKjJ^h z_MgRVTpmtjf(9CTuA#5tI3I~=|J%m0n}+jzfrUNb(BR-DeiX^j5NfHuL4p&M;w9uK zD-o4LBkfmjNxlGI%vv+P_D+`2+@K%#%LB(jZni92-}a=rtBTDI-?JKsQRa*88O;Wiu4)_!u}->%$$Uo( zJPaV%64)0>4y#qmeD|UAb83&X2|bVK(Pxs^gcN)jnn{Zsfes2U!zP#<)2#zdQD3Ie z!f&H|A+-cQd-LK*!r2|mg({Gsqi;}3g$)xqFQE#1z{%b2`3Z5rp|377+)HC48PtJQ zspm(P?@P1esM!F-L0AE^RemO4q_E+Y&3{}c`oJrdfI3yDQV76omcW+=lP2*F5u3qP zDvY=jFc5;>Eob+ZcKTIm-&f%9a!cRP@_|PS9CDPzZ<{f-ql?T$}U5u*YA=&86ZGX_%KanXjh=xIYro z`^$T|zyClM{S5AT%F8gDUDKlNlqoYCEf+iDA9+#=TUfNT;vVzBo}oPD8j;~0JtAs8 z4y$n4M5$H8Z>p}wkbp^tBiolVD0$Ka~EKp3f)-7`Z8dW6XRn z%(7{}Ws`rEB8`+#AjQCoqeS@H^U4Btq@)Hu;lRP`RkV9Mbz?6I*sk;}MlH~qrx!Z? z4B(W1(6%ND&g8%v^x_C)Jjj6;Am`0~dVL03uj6+Byk&;IkLq&v~{L4rw zk&ncH$A&Y{jr2DB|;@~EmOa|{f88$FkFmyupW|WYZJ?Hk#ZEGX81C^V z!oTmJX{d_na;^CIM2O_bgzsD02Hifh%2dVv=0+prmDZ0~s~*9`wJb2yVk{68KG>=1 z>r!-j-XAf>=?}QkQGZ#?-@#n3S*ROd;+SCOlL$!jZhC|L&E(4o!n^GGckLut>&r7p zv5|Jj9>|i<&EAfqn4El0f8YO2Uj`g+o+=5K1W%|~P#71~MNB^i8Dy-HGDCGqq0ZGj zgZuFQ?7i{!J5$c2CpQ=OZb9nK!I_P<76c0uneENXE-JgHIbR=nA6mC9ID#q?NuyM& zGxlY%gsD8S{!8_;0|{#G`AgTNLAiLue%bw&A^(z+ik_19lcXb3UOhjR()I;ErQ=i& z!YdxI3Qt9+B1^2VdYQn5=Y}=nnpojcqp7oT5vwrb{rJQaM+9~`Z?eKR^vcM8uO-00 z?`UU0tcy|TWBkhV@DMRQ3BOD7W2MtZyyrU2$w>_$jj&^ArS;T&ZHYfZ=V3LdfLH9l z?}=%l;IrSGGcyfqhfS=ZNe*X%9&~p648h|~+#)<&^PPNp5e=%tj|p>1Dn$FhYt<#Sp$ZWtp3@|D5w7m`-;WMQj?bI0Gf^04 zrcu=&u%n|PpGYyBUv?nZGz?d}!}+JtFF9N%Znm$|XSnZpx_5lF!2jfeO0OGwS{?t9 zc<+shSI^$ZH4Qkn=U1d)wh@LzY>M^8#U?0C!e1{bG8M7x1Ve-C)fFa%z96*##YlC zc^=`z`a`zUK>*{k9un&q@nJtuLuJkEx`*-7BD4=`@eXm9lWdY+B3|FUcg@(@+R;xT z)z!;ReDoc6j1i@S=V_$R<7>~1gmS6%B<#}C`rww!T?RLJdF`x=mu*Kv+Ws?1LicR^ zHCa3V4J>YIi~Ngav3nm`!3VHK(vKmZa3bzywmoZep%8TVY& z=5Io^ZVvdzsxNJ?=;?258pBj%)z;?Oc6;YSqF*~_P98#v_yqY1BM&sU8}3bDbJP%( zl*RcTP}+i*2lQzihaVF~t#5oi@U$fzK0UNIPR}R^IOYG;vF|QxAUHc+Vzr#|bgS$LTwRRXb zFU!uhod~aCW+!qLcuxXZPL9meUiSnLr2p*y_1ecv3mX$oK_wf%Llr>q&LhYx!%753 zAnw-A8Q#m?&goJo$Gp5A(qW=1{0+9M@Qub{1xbsOl8PZ3l+@6+iNiOR(?ya#zKmn; z{F$_UwR&?MXJl2HpSPh`iT}ZDyd0o)X5k~OrDp?TQG)O(`IMx`?ebJVvc*sRK1Q;? zBTtEmNWdHJ`R*{m^eNUO^}}TZ2Gv*Q(-llQ&*LuUey1bbz8G^GcSmX-pQ@ERb>=<_ zkAnF}dWqA&_z|B{mt+FQOW|`2AK;J(9=oNXkv=S9;0mt601{ZYJa_5*T5jG0J74Fx zdM}bQyV6MoK$kjPSMy6*xD*zSBG0V*V_T}H@i%MI0~A~@G6O^O%CD&Nt?9p)xSJ?I zcwKS?oF*Vs0_nL5H5YrNlA9UL7iL_N& zRK@Ye#r3}boV6Fo=Vs78_BAvg#QtnC!H~X#zcC_LuX5kz%s`h`&AD@PrI4hOR+aw| z{b72hScRL1==GRFL%MNi#>H=X2?`$P8nVX=G0wkN_`HT6?30V~ z$s;xN3qNxhs3O4hW*U7Fjns+Q+LXpYkFf&vOOSHCvVmbwPmB-zO2Uro=!X!ZdV096 zIjO~GLsbs|^s|Er@;~G%_|x;hk%V2Zo8x0;wDx-=xt-omKf1AZD8KQ!o(050hx!Kf zuuno&X|U*$6($Q7gwm5qO^RX6lVPbqN)~;aFr}N6#mrV@B>h~bO#Z~ELRl^Bx=!$oPkDs~CPM7uB()0{kBCyLw_4x8{)Ms_oQuXgw|Kai zx~{5)vqsjxPiW8f(P_`pNFZd+^Q|bzg4QQT%SYFw>9U$OWR`g}5ISQEavIEa?A*hH ziw2e>PHLL`(GIBDrdlZ9CJKB}G_GjDS4*(F?@*kG^6-9azW^X&XBlIx02kV%(yFX0 zeC3D~eEPS%16W!tRf#b#Xt4%;G8EpTN)HU^s&0;W`yRaCY<4MjA@S;V+A!<52kR!| zE)Asqh3M)of#(VuXJh%Em{QN_Z9*+tjC|D2?XC^=8;==x7jGt3S|a`^tI}ls&4h6V zsx)#o=uG*ZImpHbr#~BTXRu=XEz2Vd;rZ+pUL@2xVfwt}zScsPwRwrEofMalH^lD{ z)yJ9!%~^CyF+4R|TJ+3r`&_u4TBqnIK^;eiwm@79Ulzp_>$gdy;N#aPD{Wa4+p2!T zTB{Y%c(%NS>hX-GfsqI5l1*aeI@8~A;pH%bWeRKqSJTDzbM&Evm!Kj=Y8Q!S^EASy-NN@Xii7`d+qh0tWLp6nHeKo|s5#7T@i?aPrQ^;9ss^ zur5|sec|fjs(dt!UY<qvw$a#yuOBLjVq2&L>{=YxzOa4d6F@YvHZRAbzM3xIpCFTuT zTVor;wpAmV;!0S;wR=p{-fV7RtL8vC^DR=f^+J5`ry1B{6 z#r4P8`ZLjHYzH_Aq}3Tw2lB@~lLf`aUuCa(QYK1Ba1fIdQxSG&oD^eo%M;FBbQs*{N8^U1n3bO9HY^k1aENI;&2ptR;|AmthFpCELNx?Xq@($!n$}GuBP-2 zByQNFniYC7TWeQT>IY+vy&UH2BT6rAXcdaJt`&FGC0m>@Qg_7j1&-F0eY*^LUnzw{( zk&4<)EI64&#a3x>BWZ>_titNyre&Ht9F(z1L`Z)LBpcdtFz!-gVES4S9^i||CUCCyz; z`Lf8xY)I-p^*o-1JF3c-SZSw{6+J)w{{M8ws;<)_=5xJ^P2tsNqm}=L%o;f{AST5plm6OIHZu8`XLx zgKSmS3+?)?H>Zg}jj5aYgUz+lf|w{&8}!&6%s8gP$~qoF^CY1beqA7z*$1WcpVddH ztRHw_ut4v4-!%fOK&&f<`q1EFtg1;F#w8i`$KtK^;x7udiq2_9RRYXhY|pXZtd8Ex z=VV-!9SaAAfh-X1U?UcH8V|ZW6^{Jj-ZXIEgK;aFx%am1p;xr40?Oycy%3fZ`y?D9ce= zrHwH`R!ylqm)hco39JH01B>Kel-#ap+()R~?gDAM=xMu{LmqZlKIgv-dV8R(In?gA z^EjAdE4{OF2{hN8<2=L{eA`!EB}$$`&pkz9KPVN%X%+1729`39n5VG7YM5(gql(~b zB;NcB)&qioI_YMN4S(&ho&hlvi`>qLaDtoJc)w6%Blv0?PQR2IRh!JLzdyDPZ@xvx zXT3@WRff(ZQRTpD&*optcf7~8&f05qqHy%_;~cd6tsR?1d{X{tRba2lZG1?evaN0d z)GR~))CbPQ>pLcKu;6x?jxySx5=9rmyOqiLTT=-AcCDv!(Rfn1AUUx;- zjt4Rhujqal&lcsHYI(!^Ypq(OmOb{VxkUzdV)IXgHl&Pt4>Q2aIgNd`VLPowb;!=) z<>s4u(bVPH1{z%pegnGO#>XTTV}ra)I1s5=0XG4$U+G1f2~>R3jO(H&FXo=^yg9O_ z+;>&K(c4uNU*vH2lkk-6%-z6f-Jt;DL8;Olm`1}U020QYck1;=K_$ml1f{(tY74t$ zbG_1(Ih&8a>5n07;MsW(keqGds04Xz#c({Jy*QJ6ztL{>c$-n04J%9qOJyCS|Mz6< zi@fN^kbo#q25P<6fB>-w?g>0qr{lr=TF-^Jumh*buNyu4_M+0#P&&)0OCC41y9P12 z?Kpd(<+@OFC$y#TNq&15+N-mnj_@BiJc5K`llw~i%_#tx;Y0Eco({Kg z&wpn%pF|sTga`xBo1)zUzI; zs*83Nj;)#caHc;b)h5`6rxTCvS{&bz$TPUV4Gqb$=~A&TVOF4lEQqIL<~~L(%N*Q4 zIVV_58-%{)=JA)(z45C|N)P?`J`IEl6r^=gTHjM&OXO(myOrfbu-~O!2-eDy|D|t; zkrWm3ri!t3ma{^{#yi*VX)GN=iWh+eB8r~dB}0pEQ-$}kNGc3Tnkd~SGu46Gnfsg& zlS%(^gTTP`j8x@L3_l;|X=Hvpbq6j7V>XrXyNRS2of-@nj#z@qt4^Ge%nqD!y#*WS z>8}G_)qLa9%ARwQrNq4JSP$E&=OKytse>2XK4XtM_D=}hOz=-tVf_Z(AOH?k8hnXS zaXxG}p1MULe}ry|-GzX4yvF=MAQ`=)hVlJP`Yb$X_P_lidl{WoNav)nusvWI>9((` z==3Do+_^@d)7$?xjPMxV8Mz(0!r0!tkp>v6FX_GCDOJqZO^z^=Wu3mI*tLL~iqJMS+f+TW*1 z0YAk*lcc%Dj}D-wJkuZZSN+kuv$cZmXR4&8xBCE#clI{EHzCmC+YNtO(Y_M-zdKcHeSH^BuB11KAISW?0qN)3|MD>5 z4P{;kV*tv0ID=R_#1GBoH>+NLu8`S8j>`%IL64tJI7EcKbHd`j_IUP1#N!OHmgjPU zv!g|Y?A+Q+fh#dDw99l2>-+D9fEL%CVx&`C>%*smT`xkUljE=^w`aEk5oD;2ym`XX ze!a^{Pcb_Kw`0=UKaeXm^ChkLPzZRUfQXX5P-LJ?cSqY6JLpEqBs4`r7gLmWWU)i^LF3AZVip-u3E~@Z{XR}T)C>n`g<#!+cvyz511JUd zccU|MZ~s7gqii9VX14(d!S>D1#_Lo6mp1k8)M5=G>fB zuYz2nATR9e3;3@Dyy&>})HOAzl8fUA1T$_t`BK))Su+O*7~MANx`lpK+r9uV^8VI% z{bqRmby?5&basP?Qv{A<(3TjwV|%4~8Ogx!2~U9u8C1u#!Tsw{?nV90vkv= zysdimme`dj;DJA8FAvQP*o0obNAPs-I3d*v{Eu0(a17l4a>1|&s=zefe{9Lj|3>o0 z_baL?RCw`-$d-nnam^*E2@>l$vG*h?{a3I;`R?Dy*XyVD<4#}pcjjsGxYYqjo~Cau z(H}TR!gT0A18lWlkuNdm0R(*MHz8FiCG(q9fz$~veX-!u>vzn~_Yj-d!i%&R9gSg5 z8dbV`m)}+1-V4`J&7BeFKzmW6_dEmrei(-hs*L@*3HpnbT)}p>*UjO;b%DSbTMK`( zwp8Kpd3trntM)Rq8`k-yu$B9Dx3udMF%J^p$1`|R>J^Jg0k2xDw5EW8J6RuH*Rk z1pDiH?HtZJ^631Wit2WuSQV|>h@>UFy{!unz0_jolK*7yhFaJUnr>?qO9FTt&JCp# zI(onvF=9#dDtGltB*#A}U^zj*&&Bw$P4xu1Y`|T!j{lRCnV{;b&!-TOO|FG~gx_sV z;%Jju8*dzNV^OcgxKg$MWFLKME}8}yN)@P_LBi9!GhH=wmG!XYVD9}zpZ0>xj1!iE zpX)JFxVww5R)1pAKi^h)@+W`fHQ_(~Pk{CpQ=GvyqxpWR+wOJ?dKW0$%pKmMtf1{!>>p00H;Jny+Bg)JH&i-1+Jd!mG&V^XSl&tbPIpFe)zl` zS4xqLU_&S9QnFw{z4BI+a?@`T_G_me<;(*+ZLYLZg)PmT1;DkvE@g?Uy#qX8 z*CH1}xm!J2qhd`}m=u`;lm)1`ht1DM7scy#8;<<~jmil3W`xnMkWhO6Tj1H9W7mL~ zCsmg}aJ|v?{8bP}SfbLT1eeK-*XfB;9vh?cR~;p!qO&D`xgnqnm5l_dSC0pYy?Oar z)pBn}hQA+V^me%PSQ?HjoMod$6<*J?927&@bfh`UoVGaXfw4RXt|LGTiNMN4-E9d+ z4vv0Gj~H#7IGzD46W3p|ivV-$wjTYXBs{|X&zw@oEGZPs_bnNL4jVaAw)*epLCzSt^h4{&+4I zfN!&2za;5~He0cs6DW1jY1O1J^*iwR0zjcoSXua(IU^kG=FQmqLDs7 zwafL~-X)|KUT^U2?fQwObg%YqZsgxyN@V}jP+rw;b_4R2zd$B!ASdD>h<-b+ZwzIx zFF-RjKz&=hpaQr;K1j62MT|LdiF(;v!yZ+pl%W2rmf(Vl&UPNRmUa!Dg>*`FF3G)5 z2^%v10E}*CRMum zOLL~pFUseK00tAVD~W79yjsPOqTjuH%cW>8bL9wVuT0LpL*k93%#moh+y=o$uCu5*QiA)k7WYg^KFBe77 zHzYmHYl)krbBJn~R)~1*m5ysZwQFY~v3;EgwR(U7Nf*db)Eq znaFsv45g=+n1?U!@T;iaL11oW;Ervb>p=huA_A9Z%Zq#D+M<1gu^@&CN0X#bIo-f)_c*@8f7 zU5qnSA`%@f47})LGdHh*qDgR;V3bxCLtHcFkgEDTl2`j~;zGqj+U8piZ28o4*s&DkuT zL#r2oKO|OYc-?)-2b?fr&f>RWO3~(08FhQ%!2^2e*h82Rvv;VdUw?`axmmg1I9n4& zgC8B#$)IBP?uzYQ^4=PyJ0<%+KafK8v-KVefTLpsOndn^-LWx1_%Ntoi;w5A-mWyzB-fg&Wm%G(sqP9lG~20@i9s zdf%V0LGrg|-oL8$&rtDYDUP9^We=VHmhIcKdzAi??O!+l=Xn;fKIRNIC%V15Wlw58 zzi=NWo8AY6a6+Erz04&@xKyK1)ZGTPt-7#G?yCc%G>asy3^&l+{`43@14XW$^&X@6 zovg9m{Jask+79OOtr7G-PZz;PDDK=agKh<=#0SrnJ#KF|&mM)}J`PN+ncG@zubA=u zTXR2JTvOl|^lAHrvMHQ0vmZ&jlKRWg&u@p6jqzJ@Qf8!%^p~J^!`3!T>eq|Nl}qE3 z6AYf#tF+|N03Io+?fwkwxW%@CH8DCJDQO!T=|4%s3DYAHH*H0Cx|*+T128Co33{pT z0+^4BmmasCH7i~8J_qz@XNI7q+)KBB{sbCyCFXZ?+1uOwN5B2dJeQKffzkTpa3pr? zE?eCEdPbW)mg`BZNWH&WQ{3)?nY>c?3SSZD{_mZ)K2q%VyJB!C>3?PcJk>s0?Q);e z9gci9?%h-rG=?eDoRLOWZZTdcaeHXGMf zk@dxzA8mSbK1UMryi%T$1F(re!TG9NS0{-;=hAN(QuN| z=g!mDxMGJt>Ky4r>b5gIvXsy8S)1{ZO&|;C5I`mNSAgQoQ#OhuEFVT*;u@k)NHxi= zc1zI8`5sx}-fnfcTuokEo~`bKu5-M`p~P5BTT;Puw`j=Q2fke7)=R3CUk_SEX;5Cd zZ%WL|$h%rYIQ>gt5I&b)2Q|LqGmy#JCadaeu zd>@|IPU>CbK4}LNP(M}CGad>A~ z(5zjwFuOp1FgSGsUzb9Z4#S(?Sh_d&v+OHSijDkmNt7^qn3AAi=KMGZ{l&L<(x?0P zh|sC=A740+94^qS3Xd&pVmh_=e8|)#!^s-;0`6;(H6VEDw)j=|`wm4GNqqiW!BCnG z0kIX&hH|peU7LAIvp{79hi6W{1X4G?m{Lsw z!4Y3Bm3oQV0|#&eT0z~nU)sMw4!AiNpC~O&GCAnrI~f*HIM3pJ2Lo?Z=Z6#9hI&g*9uGs4bT*oa&S7u(ZWm<@{Z7-RJZx=bsRlgWf{sR{D)pK zr3xzBmr*d3q)&1wwH!hhMrkt0iO zsy$vjX#hrJ*sNM(x?wX5U{4aE*N`eAv!dz&xqqs`Wgw>|TCf%WT@|zdq8VRz9W!O@ zsA|&Nd-V1x&8~gNcseu$-v>*sx)!}%+J_WvTf_G_f)*5j<-$F(G>qI%rMfy=$J-eq zr9Tcglwm0EKnT7>p=rVYK~Ej*5uI>qQ1WROWr~DPb$B#f3H>4IefkIY80hUxev-?} zEK3(yMlrDhr`r`I4}ZE!FKlRBX-0|lvHdvDqF3{6LFZmj2l$DNR_w0GLziH_r%N%B zVxPcxA&5qej_>I)uYZ3$C07{U+oR^^cb?VvbNs`)G0i%hc~SvA~;r;K6bsx|mdaw42BG)+yD3lhGJFdi)M_=tf z6B*GI$#*uY!L|J&VX9KE_{_|1>&>kh7k!_9?`-I4%}9}UcTlw{?bRu?_d2?={ps)bol5g?H!kv*#(snP>u}He;9!Y+ zfc8ZnK$*e<7V@ktz$pK%jP!IwRdZ7?`FO7c47QJd-qCgGQRR7gNY(7}D42T7G@ZV) zBtJ*gLf+2~PIYV$9dV}J0?0t1hotI-B!=(Qt(SbeFF(xLuWY_(fhF#1xvXt3A}eE< zRFShiy*P@$;`YgVLY{wLkFo5k-F#y&N%0FdYzUVp?CNqSd!mq2lg*)&haMapROukn z%ErERRS#ubLDniJHGjf!JHosXCwMcT@3*7qzJWGOkvUL0IQ{y--C}0Nm5^Yo&${wu z!94dLT70lQfiLPOpnO@iyq8sdQ}mu@!RG>2oDtLq1vlviN}yV^E2c376oREyCSMlC zqI`%HSBZjfWw<;57ks-xVN$(O&go!M6)dQGh}3$m=yiAx_=pIf6v{y__7dKITdx6} z=J~r(F6}CbS|RO9*Jj$-Edr*w*1LB2iW{lbH1$CaxT=WNIzSRktKP?5bUmw=cT(69 zv8dDhZ8~zLi3IWkyvFlfE2$tdsKBYTv-q~MCa+HKuy*m+)GgdY&KTV(x;^1rZ$9#P zpiZz{k>*L?p1}K`gXCP2l%vS>Q~}S+bv((`fezc(s^<5C+Q_2n;G1fs?@DJIgJ7DI zy=&Y0EJ$+$?Or)M?wtGm6zlx^tMe4ZXx#~w?~%V>={t&#UvMml#6(j@yA5i%rpip? z14|zih*GcVQELdt`C-7w2BK8K_rh!X(+W#Sp26=PH5>P454VsbwH!%a?sy-q5=R^G z^QpE7#wMM#H^wYMJID7pNCfyt3XDXZ>}fFxRL|4P@AeIos`AJT15%4l6B=I()7<9J z0hShD?-Hgt95JboHjr9UUXmpws+Hkg3M_mG{(1>Lv{-atfJs$aWR;WwiGDU+d>4Z6 zI*Mt}$_1k#9|(1?cR858;@X@>q+AEo=HQqz29q@D@7bc+LmKMHE@zppFsxHGK%bh! zw}Qf-cbJOqGHO1&txSHTH`G8bGR?G6bURg(>_X;M*IatkxX~uHp~pEM@Jg4F85W9O z8UJL6?{K$Q7IQqFVTaORzx&r(T`4%&9R5Tf&OqfhMh=aShA1iE(=6+Dx+k8JQuI`v zE!S8_FZLnUU9&|<9vM(J7m(+2i2j7l%XDNmne-Jsf#X=wINOjyQ3Gg#1_(oXFN=`A2g@r5+b>AAcp%^0!UqG{q<(4QC? z19fU`7Cp^8v0|-fypV^Kz`&;STy9{SgrjSpbrbNv5ZZV%01z|&>5-Kj``FQ!cgG5p zhKqs=x;%`qyO9mF^^LyN^MRq6`(3#)a&Y`4<) zIgwh6Q@~CK>A8V*h@iy~o-e#e^zUJ5ALD>H;F3d&FX~0W1qb;Tz(0q4mrHHex2=X$zI+E6PnBkBt`AqBwsaopmYeFn7NH<~ zW|BW}I_OWgD*o82Y>ai8@`>Wm6WuQ~?)OYA+4r=ld}pZ$vzs2^olFQ3V(gu}qB#0z zbexvw^9k{V2Ti{(ud@z~jWi`iUe_0@S^x>#j*Op3QAm5{;v?US@8wYDW~s<6@$rP) zbPU6ctJ*=!iS>c>J<1T8Oi{;mQPX#{sl1ew0b*{JlS|fAP#aDwAh8>HR5$zjw${HN z+>y|Kds%7Tvp+(;`w88n;uo=V_5G>uxQ;eVM-1pVynZF6h+}*KF%cEg`@i_&=3C|a zesZ@WS>~sF>PNa9rotG02_!%1*M}dguVhfm4S}y#tJ$fvLeg}UKC`maG)ImeHwtjb z;gd$Hf`3y)p#`XD{RTV5w0^7zPV~{+D?zjO^m2UUO}wl2EKCknz zh^*JYI9s#>%ZGic#UJI^u{4|?Z5-VBMcGmIk;t0_xLXU~%!4U<@!Q-&smT#*=VtU_ z+oP!{(57P)f}YTQ8Ti{#6&c66zSs3@;%vMIuPhH2`>Jbtnl*kY3{giGOF@1Z`|`Tg zZHQ1}^ev@1lUvjap4DnnebQGPt6Ha!?q*Ih=i)SK5YrT&OY90Me^vI#i8WrH;VkzW9*?kss}G(c zimgVFSRr{1L&km?10ocQN|TB=?0rbS54P$}6+N@eQ@@R3g=P-M8E`GNBx7rEcWu)( zOCm8oERh)|=bHvRTf_U1ROb3+z)fdJ@YZ=Uw6OKHc5hMRRMb*=UG4c{=_gyLbkWZI z3olxmvSxq&wQYA3gKnst?QLmES;=gptwQ=Q9jIj9C}l_(|JCOj6hi7&{=C zR^zIt!@YSn6Fy-GrM&rAwNBdJGQ(6>fLs5G9WnCUEo*DX4(zgXvY8x$V2TwteJ6|O z1y4{U^^(g2DM~;pc#({s$uY?Z#3?Ipeyzn#b|yr|nK0&5*dRLgZwmv-Zq0C4cJs*S zqu5(^cJ-yvMdbJjG;X;}`5am>MrhZ(aiz&zQan8 zK`t!O^M;ZnwuoyT9TZ&>iGAVGk27nMeSX0&4tYpow(xEy z@qI&VGU<*&cX~5BC1}2AliA+`4tp^KZb+b_f@e~46BAqYSTs;^iJ~ALnK7JhRTNXuZ(sORh6bdAig-8lSc?joFBs_X4q!|L8mn-BvG5-IF1?zXPX8Z0xq*lQC-6lAm^Mk{ zoXe#8FkSf?me^Kvu;h&aW@~TLi7HP8D+x1PqR$dC1SyV@5iTDG3N^cD;rIHG;8j14 z&_?vQ(q&3;A}*@wtuK6m@NJOb%1pC zS!Mp`A*Ki_ctXjkeVEP@#y;jCYZks-uH-45=gP!{u2^)&SghlLPfPtSrNs~?`*9`5 zt1x3-rjVN#-;3_zbAnA!tH?J-7udB+uOg7(wqG}0u%GFFPDCFwnysqHPK3h!@W@B~ z60O9$%0@pJI74lfh!ctam_S4@5`uCpt7<}h5?Q+`-x8Gcgpqz`A*Bfx^_qPZ?x=7C z8$e;}+e5&fyAlqb!IRnNW1iKCnT*vDh8Fptf`)-;cgU(nLog0G!hX+Gb;gqb$@Ooq zaUt;RCdQoyDluX=0uHf9!NhoYk4P#I9Yh2!Ebf%KVhA&x!7g}~9zWrXer&3yOe?QV zjuBjJ@i>`Kq%=OTy-d+lUTc7{028;!isl2Ciy@j($67D!;t@Xo+SgldH>@W?-?No<)8o@g~Wr_aE-?&8&GZH-b9*V`K8R`h`dk-xT3%fyFP2DIps z3Y_ctKlIkP(s?;vQ!|(HM=d0`J5cp{!;NAmi+Uo(AkodI7)TX(`X{Ev zCJFr-E_3v2doaxo4C$nZ7cQCusb-Eu6U&M2DwXk2#x96+MII%nu1a(_h*h0MsnH|Z zgawjEd;&K#5wD~(LLs-Q%=Mq+cVCq(Va?kP8(shWfTjvU%mm?36TZSFiKz45FEZGK z5d1t(0E4joz%cS5th6v*n=)0}_af0xmmRb&-ZA#G@zOiZ;?0{YrO-Ohjx2T-QqlRz zDk!KPUZVe7@PN<8h1y|vu^q?6aa)}vx9Erq4f3ZB|1In7ZfIkfV# zOMLOl&ilr#7yBX+3{drZ;L5aSE#c8TK#|zOS;3~-qFmd!A}C1p!jF(az~rg^z!pX2B{G&dduXhPlnl1 zGQt>eN#6^FY22wpZ69pa^v=IKYI7RzceTF|cH)&9{pO<6{34cEE4FA}cf0fc zqqvs^>JRexYODKiUk+luc-gdfbH#6yUlhq=)h#-Z`Uta7Yed~E<>qp&H9_ljXy|#~ zg^U)hVo9u~TPa1Gk)u76o}}NTffYn|vKL@H9A&=U7+ftixGNm7%zEdJhSVAATpj_@ zSt(qH9q9C&L+z8yg{WVM8-AFAb=YFf?tPFI z+Y4Ppr|B|M{^;V94^uv+2vSL6b*UHDH>lEj0hGfe{Y^#o?<{p=adVr(b>qP}kV7LD zIqKd{V;M0hn$O0H>h$2Q8KK4FN{il|$Z#ifAS_Kt$TzaCgmmapU8%jgO_L|@2C_-_ z+gX)bzLOV+(P1usJW~KY%E5MX_d~fuYFemsv}KggYAA4hN4_BarxsmU{$clc2Tu3a z1o`pKAdCZ5wJSK7e|&*!g=8V$=``%~%ZvUqX@%CA9dH>oAk*njz3wA4mbyfCp@Lyhhi6Hm30t5bTm{ zV^_k{*3}-OW~pVN5xE+Fn9U{dTQL@E_4UNc*p2AI_9rXq<#0MSp}WhEEk6R_ZExS4 zV5@oh$c*ac@5DJs2sOmK%nX!i);6yo<&dqM|MTr9VtcB49RR_2QqWSLf9ce{(?y1d zNIB^Uo;WgN?Zi}b>jsc9HdpMrE?g`Jn@?+c%yqGguuOGDpI#05&72^H-hiaz14sY}si*;VA#bF{;4aypDx5xHCd=K?MS48NK?z`~&d1_* zj5(vX{g-0Sux;K9iO)7_GHayhv^gmtqEJ||o;@WL^(T5#KuY`s5-XY_JngSA{9mM@ ze1N{6Jlv7+^ywKG8;szv5p35tKk zzMZilOtwDu+8I}aG{u`Ur?g!jTiBJb%vh>y)6byt5=wQs|LG(*z)3TX42rJi-m^Df zMlN3WDAv-WCaaC>+jlcNiYj?vxFEBKI*AU^*&^b3Jf}QJ^1OJ`@zLHk)2eLmkFX^z z>d>_Nf2nqCD;Kyz3CXiEs?G5$-hJeWn-9rX1RPG5x#}GGcVY-stGkORlX8_5JZjtUGzJg(a?#QH{T|1`w8l{awo> zh@ZMr{yHw2)bxWt^^gTqFzCcWo@6m87;CHh5KgB7l-kHX{wcL$TEGiVtI|8+uDS>i z^2ds!DgeQ_+qaMe%CVgv6;;Vg%#v zYaIx3Z>ap`b-m`+=o=bo)l`btKVc$}sf8d*WsQOj_wIyfh)SxzSYH#l_m}>-Z?~rW`CL-)HvMxWCJ8h&T$UMT6is=P(H+Gitf5APEXLL5j~WyW`zBr z>JxM6EIyEi)Jv^MRLUn^fbh+B*VD?|z5+T~??zzcXkZSAcFNLsz*|_e5Gw5|d) z??a3zi1yp&uRyc#5@v(f^E-Gp7&XF-Os|u`?@(Rr<`VkU$9<{iqd%D)ZE{SnO9D^h`KoD6aCy_VGZK9C)YXsxieW4W=axI!z3|hC`ob zwm+chk+ruSxKaqXwjVz)oTzF-dNvAR_U<*YgN8^sTYVnH+&b+`mWlV37rTBYvdxlm z)^2+M-c_6u3{~9&`qO*>TO02^<|&qOkhVUQ2E z1OPVBYBo6L!bogR%VQy2z6N)D+ZMBO(;+p&`#*XW4lPdf;IjNr@Gg;F|er!8Q$3R`oi%2N{J9F% zocV7V`9t)$R5S}zT@ed7X`3pD?hNwk5Eae!#R1k0gR2=f&9kXqLMJgzgYCzJWM=}@ z2MY1iYWKIlKU@X25c*?m5qZ-TVJiI!lhIF)W|fpj9>E(I$WxE=TZ9}Dvw|L^vgGXV zWmIoe$S7TB6||gsZfT7H*3>g@7%3e3xrEo6iB>`{xv}mc0v#O?{D<*H>|NnF*mTab zs(>FMxoY0Ai}NSwNS$4|{w#Rcrg)3F^b9Gcy|pg22!S zA-hlBK9r;<=h^_9=uBOgPa_{~cn5d|w;({ielTtfE=ZZs2TY*y+-5JJ^Y@2gMnXQs z(Q+bJ{pW#S5>(p&X2Y13uQ3XEOB6yB4+fqw!nqn5Cjj(1InJXqO!=?!QJG7F$oceF zX-xuAaXX{g2#JfTYzCmOtTfajN5{f_9MN0e}T&?N>t_L{?subc>8fwGLnA0;HCN-x$6ea7H1Wq!>lwlbn1>+twpCD(N0c6Y}ULbI_KV-X!6 zxk_EJl7ft#W?eLEjy-B3-=kdup?FoEjR;5?vSlIBH{iX+kO%HuUp5h1AU zeR~yarx<_WP&va<;5!E*=U9sb+Sv#5;&7?&)tEq|mb{nCHu;ksyHhLQjo!yYiu?uXxDu`{>?aB++Ykb1+ZFftE?uG+3BZ!gCw_8(K8KZYgk zpqH_T2lYoMcLD;0C_y;!F-UPS=WxE^e;<4mdZhe-b z(^JB(?w`!hQH`@CB1u~O#lW?F?VcQp}0H6;kVe8 z{>7?i4hx2;3TXk(eNV||BE6V&4n?NJkc{Q=xDESOaCGhXNXEiDR@(H{M*<8^H;5N0ho+MH$gXbk2M?C~%Qy>S6lvq(3+=~z< ztX=lNYTA45aaDQoCL6(iw<=N%g25gRBZx$-ICA@0cEUZ>o-3VCZSVo+Ux-8gctEv-{n z$~)lKmM>!eUM1#*Fwx;KQ(7iv~0!-%fCM~>|Pl0br@4nI0 zrLzR`0+Xnl;2|dZkKlmQGgQQ<9rCLQGsCmv>!)(`xqZECi6YG|wwSx~DIMocuU6+L zrEr(nJfJd??G!^8Y3&$C{psGBNIAJj?i33U%edt<$#9no&N7?Z;*ERM9WD}FhZ+Tz zjVruy^bQNjFG? zba&?vL)TE>#^;>#e(!mF-uXjOe?RcP_TFo+Ypu2SU01g^Y_4h0azDwnk#W_7m8Etj z8Atp_@SV?-SUC^RcReG3Y`QDAm2RYhS#2jZUj-Vlv~hQ^!Q%WV0_?`B8fDA;CDrH& z)DPM{={gR6AK-Ma0)*(IrNWx#6E>W9q6@Z-uen{5FP&|mp*uxiJ@6(&Fzv*f9a#@6 zoQ;XsC!%ZS5ar9Ic)xHF!MC^{Q%-$3yi?BFT_1LrfNAfg{S<%TG{Tj5ap!^HKwX#O zX)zQe=Oim{RE#Z(#{j=0x?-fg1vN9S#(prnPq%Se`HTb^Ng`W9wUF~@4<5_CxYB?K z2h4a)4Ue}Jb=QYJ&#i^tT%FX{-eKXDY7!HHc{UTKaID1%_oLjWgvW+JO-c;{P;bSn5x>^%xp?i#rg% zn;sutrAd|e2+*4&UhnE&eaj9(ub>@>gM|`@vKHIti2b4t@fg)7pWG@;Aia+GlURqF zXYWn!+Uxs$YGRy&U8qXu#4ZLOOC9b$B0!F?f@%j}$-_}qeV3|VaOalubh!8Zd>-?T z#!4moM!3JtaCYrS`yZF=bZ-0R`I)R} zC(zd6Ou#2yg`s{_P+g>q^Gfd3f8aD!Mka8fB55!b1vmOd5Wx~Hy_c#-gQ)iWO2n@m zd`7v*UA}s=-zwKI9c+*q8O9>5z+BQ;UT2>aM8)Lun|lB}2a`RB41|hM{@jPx20Xig z&d@H?`+P>hIl2`nK++u3DL_Qx@KP6?(Yf*=zWl=N>s^5#W0$;V;wVOas@;E(`)ust zS&zY)KJ18HP2ys6Sl@eQg;_2mmieXcpwcl*<4DO89TZ&Ow&mgN&Dllv;ov7ljkCjr z^`Y5=H*qNE@*jvF1QjLaSApy?Yx+V4-j|SA}!G`cen;vWjd&BN0ui|cz$y&yK z{$=^r`L#c_g5m|mmm(!#$}(@?Co!k2DYCKHJOxuH4zcTfw^`3oOCvt7ZD zEm)>f5Jwz)_K6@E7KCHY14AzfkRa_SCl~Oln7D$cpexr~47+BZp9 z8Zg5Ll3_8Ezok^$#e9n=kGb|9Ew&dnR4#c`#7qn?6sp4uJv=-nnhiHEvLM2d3~$ag z9c5$(90)pdW0B|`iU-9F1UMTX5)N4cn*>0E#x+;(9SW$-|Ap_!wWB%m^FB&1h2g`*F}BEi|&Tx5(}gtk&1^ zR_V5*OO4A%q*5d7Xibt3kUYtPNtnp&gW|DEQ#Y%BR_@n*HSX{XinC<-g!~XNt+g|&9BBJN@Yg)a0 zIYn3j$?nz!f^>f*iOGyNZdY^2b%?y?`g6|AH?L008%CU;+#(4O;~+&>fi6K4PZJ7cDT?pu$}QRXKK z_QI^@b$xzt;>&H6TPfr|_SMZal?X4ZE4v{wlIuUl9(Z2PntQ51ZQ$@yF}zId)HX}A zDGyohTj=+i*b@;xO!*RD1n+v+*D}{EEZGx>lb58Y!?`gJRs*l8EQDJMX#89XxGiQb zHM?k^|Mco@qwDuPw&+1U9flEg+g1bw_XZ6LMMU78f~5hI-$oC}w<9*D-buxQle!Yt6-U>EN*4op8A===l{Dm6Ll!X1SN13(jS z7b*!V&u2U-O%S+wysk8-^~j&g-^={xj6_nU>6>KDWT*@4&N+izsyX~4YGW>YUft7GGaZ!7srd4mb%Hk!eZpY@7~AKEUG^M0^AqICIzy~M)wEaI@j|iJo%IsBLwL_)D8>MUaLJ) zUF<_qs4`dcxsS^dS<0s)5}c^yJhRmG8`QU11_M>Np^Ej8jO;?CRSeD&Vb}#6g3CV` z2^xj?20Yz2bgqpgwl;AUMa$4yJ8X}P12piSwxZ$vALzs56yZA6{{%cig{4|GDR0FtFd~lFvm8beWd(=(tocaFCG-L2WCJ8UMPyPzs!TkSjiMAn0djq3b-MfKSj zlx$tvq?{CsPq(nKpl^7*tKVC~s&SqFl)m)y<89dOmU0I%!B6aG_-_E?Pq^cPy2ffG z^Fcvo`m-P|?m=%V9YLeMvEnl24C0~|_kKQ72=aYn@OVb&@{gDD9?)?W@X5%L*$h*&`WB%dVAuUO=}DPQcn<=)+Wid%WR`bvl=wt-UViKt z+18YB?yC`UOuYqy5Q&TwH`#1pFdU(3K0huYc%zm(XFz#=!^>H1`Ja$uGT{XcmzD~z z@yNGu&$|QysTA?#=A#kMQFNPx0-qjAUnZG+h(BF;VtNjaPz>bfu$U;_6${MX?bA-{ z*@Fg$qSH#1OH^;v`rVCbj?LBYVL+z%!b{HbMDNfNCFx|*Pb&9J>e)pz-opw!4eHmV z=*E0xJBq}6_ue&G^08eNy{TH3qtka|!@O8rtZg&hgBH-t!V7dZ>z^YWH{Js6;dd`p z;OkBJ^MBOj|L~c=zUby17L@5Y|JsxY_{~&KJ1dmrN}YmF!~z*CE&~DijR^=cPv&qo zCkFsdLqFG7|67=`1@#Q(+*QEBAY^$ zCAV^L3ryh~o=D%g@x3}}p$fr(BNeW$1I>p`8C$U1QE@YV4wS6)ncWgT-f8j#kB`-Z zrkR}YCOO?5EMVz6C`$9t0*T#qa)Tyt%E+60bas&h$G?+!vv7O%pkvL{U+VL-lUsU@&(qWmO1?~o3u|fisnZ$E4^P8-?Kth zu{)b2LR0t(?_GYt(X>yULT0{oSB!UzezyI~Wjupvt8Xm6&81lC{ zerm-3g>)FN2zcyxU#7XxDUK7g3?12qlw!Nor;VpKCkFN7FPy^Z2{;dWs;J#&W%)G6 zBgQyiAuEhGk@t}sXD-hJ(?1HWfUF>~mNoei`z%Tc7;u+w^6)g~M_!S7kquQKV*iP- zG=27Mdm)q3T4J%Oa&L(lUJ=tRcU$EVhr1Su&9qt|>d}>&M{@^#j-egJ@L_`ybS$3a zgjm4+oYOBhcQ5=>pJ$c+cW{e!MnTu%?c@I=xJ7c~U%}>P{VmRQPuY79WcthQ&>u`w z@7BFU;pm*)y!P3t1-TF;*DNne;prPsOZ)Ejf-uzfTKZ+&JI~5@bO%(}Ro>n^!WpOg znBT)uUw5_II+^fnJU3>jKTp7{-`$eMZ(SWS>#W=htpBSdM-`>sgPU3#Wsan-R`vl( z07Nr59&v3tZ!sOm9um_Q+!E?j-bi)3;jSO&aV>vtXgIN9KN~AV8A5OkO!H|*uhGq( zSfxm&F>Bwi;B@;i^YMPC-Sk=~`+<;~9e%Y-uo`;fanrlz+=cr}tJMsui`N1-l^$w9 zetymWU<%C~O!V%gno(^bR;3<^zTftf+s6aSiYOkudbMKKb9QpSwVEVP zDsN=SERbt;sAuOc^iDTQO?Ni;#>Xp#eK)?u@~o_XYToVBY)h)xk&Ib;4HPHV!m0{ z5Pp`*pjY7V-JmjxctORg!!toXkXA7;o%?zpb7tEKTs}MYGy~`7 z>_mhm#aW6zGr}B?=jXi+Cc<6_cHUFs@Ax_M#l?HD-Mz+mOM9pJghQPMBq+k@3ueXl zbSu)eYRmUTOD>SijDvUZf=IK={01J`N?^`3!(bKzbK5>87sZ?Q4Z^h+*|RsuFcvC3^G z{`}%?{Ly&1HfgBEg8w@U*+3cc7a(ZbQpzXQ-5Qx4UOCh7`XbImX8saI2gKo1jz_5V zr6AyMP}1=$-{#l)UqmD?+8g$phSdH4G$aXXgDu!!%J`?3ktIs_bN?Q|%l*qo&j|#u zjp78-&MX%fu3QLAYkrl>efcLt=4%nr_X_~YXM*2&1p7^WG%h~9$? zkYqx8sE^CEJCQkX(+D-KESMgb9bb;wvXCAGS=tpw4<-L)NQGhb7s^qVM?uZLO?$P8}oH*++7qJb&AWCNs)cx~&_LW_XnVfFNK^Q-z40e8zI^gF1?YeR zs>=|KNBes3S6qqv30@K8H1kxLO*H1T@EL@*jWC0kd2)I_n{gO*BQPIfnPzgmp;E8> z0*XU6{IDqkzi_tI%l@6Rq=D)EvPS-lgO0ElryFAeW6|_iA4Xr8TM68(lG~{bZCz6* zwPIi=P$Vq$mB;(#<7DJ}qy3QwHLtHV25}#M-h_7&Qz+ic`P>I}m&(KkIw)BMJ?Svl z-O#R(y9SV#G)$6)^hc^yo<;Z)O#9cEJj1he>2UdcaBl?Pd9CuH=nKx{FGHU)bu24d z@}9Mh=q*fit_OX~Y#=dg{3fn_wCf_<8tvRsso!|lnucOgaa*r6JnDt7?cp51>0g9QXe_3(%?5)=fmmf#M{>+m^u3(K21{ zKf;V+HX5CSxlFHvk=0IlG#J% zL3U$nbS5i?Ev-}8tByGI=eOv>8^_g@DC|jZa2E~_990qG&MkZ~QKyVvp=zx^5|)pN z-q=s{d<_*rcymjqA_(XpRCAaA_MT{26)@*lp`StM zn%6Fel8945&Gd-XDWsBSMU%dB%)HpM5fw#z4GOzsNpe{6vL5}D%{Pd9-pfDwxo$mu zbK4@sEfG>UJE+!-SbmcukT1%j$+7Q^Cp}hOuEix#qFl^hp*-$k*}WZWsJ2tFA*?~* zmaJz>-bi#2Cs08kI%WN+p^TX7u7+ZlwTwxhBICl+s_3qlW0==qorZ_g zI{D~M>sqDgqJnyOq>J!}7V+;20)nRwx!^%>_8;bUPkj5>^-hBqx~~&@I$UXKF5KL| zIs|{A)G0$!w0l6CCEdp?bl_(EwG=+0YnqiOUXH;V_b7jz{k{-ak5?F$O;^vMNW7ma zWepG8v7?kDpfdz?MFeq^@52*lNZ&xX!q`6B-^%%u;41(eoS#w;$t_1pSTjux^T*wX zDxP+rO^PjwT4cT(r;n+%J+s>eXGiqRD8guFUK}r`SqHj21;rH7-x+#MKo>x%B~lLw z2}!o7H{2;vG-9X;)w^BMY!`UsXEvW%)pHaFKWgDeg>z>Y?!-48D*BNDFrFRm{&*277J zTWg~?mS&w_TWjb+;!9>S{2`m{*QlCKO=f5HXm%Kwons5BAq*I2xZ1$m-aB#@b zUoS!HNOcFZZW*hN$|sD%t+K5@uOjMQN=uo(9ld7v(>RpgrDbpNFj-Q{mc4CQJJr*C zv$2C`i~yOi31sA1Wy`Jumy+}X@7|J7!7T4k`lp893Z&$_H&cnMjdzNy?=PuHk_I-z z*XmO!g@%*PYne*t^c~y+^*}q>1g3=Hr-ZsMpoTkroHV8B6*etb>*4;Ep ztv*Dv)FP#FPKGikdW%qxk(}ITy-|MuU1rd!71GSQ z&D>MO*Knx5hgzu=`bB$b^QC%dhR=TCs-Ll5A$5R^9UbndlS}9Tt*SZ1u+xgS#89I= zy|90vs9-2NFuZGUlpq>TT6-G!=}k1U#3_fSvf&CMxt4$~eFqRoq&D3Qbr{#&i!}l| zpI|U4|LgO!*Fp!Q1M14%gDZ|mb}=v*FSD@PZbj*Ftmcq6CwCOSTZ9zV&ii>7f0MGZ z@03AM%}~#Kyh#y3Yzfm+SjW4*I7QSgGJ%O3%kH$=8_10-O+s$@OKV8dmz$isy@r4; z3a-;mt3}HqIAMcHRB>A2*|x=gF8hE~_%6PWx14&dd?(9m50%8C+=nP#9*FxgYMXNM z66rN5y(4oS=V$K%o4{17t)x{{Rg8LSj1j%j#Om@JWoC1L1<^JVl56iZ=cieNC2iI! z%XdAGc>Cc2e$lgeA`)g&d3yX1ba=_=6;cZ;9*MM{WsC0 znaRb@VpPzcov#QzUQk>LE2^0nCwOW=2>S!HT6q5jtzT|XL0iMKq>)ZWz3I`7`q52` z^atr7Ou=BvN(P7^K0_ctb1_tJPLK44xvAeEKpyw9vOKU1p_Y;G<%Y;p39S;)g~I6u zo*70(p_+1Zsq|78pTV=ZVmP!~;l9!Ga@ahds*^XxW-jrP)#Lrj3Wks66D8Lc%XqPY z%(+@mQ)WKP;NVDky?teW$ZiXB@vK_xlowMJZC7J07E{hL=!p~14qWIH0odBh0#(;K zI-xk}%;D?n*V=K;DPLXjITn7*$GM;Y&gV&!S^1(?D#QhzXy^Ihw@EY^<^=SPd$d&5 zxLQi1Xbdwk(BJ54D$zHA@p^n!+bZHWx?b8$^#tX7M?LXC0Rf-rQ z8I8+BVEIcNLH(y$`f~pKI5Ez1LI8ndro)Szie6H}w!DhfjUfm8rE&CzpLDjRQI?KW zrILDjY`BxHM84d(SU~dS8moGr)2bs}Ev zWVnpA^fla7SI!#sq?a;gD9w1Ftv~A4GT!!0WSk_nTD*Lh(%x(^yXx)-s2P`PGX7ya z#W#r{FqT@oZ{9xbqo&B)rN{%f9odB=D_NlSof1Z{OOX9{+7-#x#ue#>b%iPI{em{u zde=^$vf<4>vG-bAqb-!oA~jtLCtgt*dpGV8kU%p?;I!E9RbwI(8J2Ow$Vh&y+`Mg_ zZ(mmepM~fqC@GkmQAxBj)C5)+|7+>v;5;P&Bp2sn)N{cZYQtgM;=wO%oBjPG)#{HC zbj7#Y`eu6gX(rz3^Mf1IFp;BrRod(G(` zE6C(oo#07R%xGZ}m)h}*L22JrkA+iFY=b#6=Mc z2GI7Ymv0jlxMi|$-80Fh5$x2+#|I>8>phkVdMz$L(!00o0POnGF9a#g7{IDerlx0_ z(FrN#h&u8KAtE;~bJ7i+uo$>lNcMg+wW*x_yox&kUS$RLk7BJVT5La^s49yVqpeO9 zIKt`cz`hho!IQG1Eu_rV=$=S<=;%u%&4a9BSo$?@ZPOlIs@ks(`>F_~jM%Aba@tw9 zH|q^Np{YmA4I*Ffd>RThoy8{xpmWU&@ihgT2TAi0_|>)*J2E6pyNBw_Om^y=g=p z`mCVYj{H>gB929*EebN}8zA3QUno4FK0)=ZKBQj$%gj6QdmVLn4tri+nCw@xn zkpY-Z8@=(?S(e1SV01tRuNNi%cW+p8VB3x1q3u`5A?Ku#WLjlHB=$p2bj)r{xbEtgJkBnNvz?!gc(BetN$nDnId) zC!&RfIi|c%pgnC~fZh{WhHO1X=7}Jv4O@u^v0!|dul-kDad0fCd9Cb{K*h;S$E4TR zQupj4qH>0R@Wf!+2WU#4GwP?`;)gKH{~!wKluP8%Oa0~zdrnVJW*I_zof$Iw8hWjh zmwWDoXuaV9;tCbC&bm=+G87%aJ<$j>46N(PjM;3}DL~GaVLawa2j{p&5sGogn z-=;HBWF*7YHecX2Ek%D51@0Vw{~c$=GyaGo{C4$ZlQoRX&vd%H5?bAmd&=SiL!{3L z_rIob?v=RDlo1mkYg5^0sM}qD9|((j^5?}(|F6Y8O9eGH-|r*D7gUWywX{p4u*dZG#n_pFe2jUKCTb^W_FOW@I3P#SGII6uT;?UQ%xH^R?|#XOh|u)lr~oX8vu) z%Q*3DHW{xb-^6B+hTLCb)nc1>2(_88%-`7>(904?nD)ksj>r`6EeQyXXJ?5vAu9Of z4&qU}r3za7&$}Z5+8CNN8?MgoQW$**V#B4trL9tSCR)}jr}o4iBa(|psna3SzP(3M z(bn(W#wOXf^0*|HV-K*P`*|{BV>#L%*i=rB;C zn;yfkHk)r_ouR|eO1YZDIP(vHWij@zw({O>=77wpDIPBpOA_a@L@>Xaz+l)+1tV$r zCXyW+#x|5hQ;i2+tZ?WTq@~j`d}wTItk6QF*FP(GWX{aga0GW&`B;Bv4?n{)>%xz64BMB{|H{P~CzWfcGP5^)a zo>!zk{YMhu4u9enksuv^&MnwLNNK@Xu*gw~d5$FMI}?YD#_GcTR+p+K6nlE(XBy05 zS4fl;hBn{FN=o~))B{Vl`Ck;aMU=%7_PiHwrBpu1_xd;3ag zZ`(%r_~J=@XH4F;hf(OBS<)R}7~B?6X(zDE_dGyUFH9 zl?TD!Wz3;PL=K6w%@1oi@$MDx62du@ zBAmMOB-OE}+DAK{?d7(efmQTcsl2-@p3~({5S#qigr;A);QIGKl5ckfMlpT)Fngm6 zmOe4}sw!e384V+Zh07`svZ`c+TwLOmI0l#IkuO#b={n^U)5i45AZ1%~Vka$I!`S5$ z=bdgXjDoq{7M@=ebn^|@+_PhDtF!x%{RrJ$A_h!AW?wK&Jv2{V7+MVCB;CU!V?wS5^kDK;)gvTiYTuz#A+zv|17B3{iwv%HL#5 z-ZM#|(ft}%pG$BGTJwq9O)5Fo?9xAACQz{^v(>zU3_I!^;|Ekl+;s^y}pYSNdfy*oW z&0DD?l}Cx9yv}m2tabW*ffJe8M{8!TC!d&ib&(UdTyVRCO^Ns z)C+8HN+~W;x>d+FgIYkMCxT(;Qc@+jhYDzFw6G5E&_82|<6n>4V*!w0AV;R)y|B$z zVCZ71kr6!kKp`}iX4|j**?g{YRFt9A*3%niY`tmkdJJ>}xB~<<5vo?`&CwDvb*3sy@cS zd9WAB(j5_+VWVy3iB)7r4}oyCAMj%>9@?b z&1+gm9_IduCa(cTqW!rc?36$zP#9vp|3}9AQCl-*T_4aj?y`VD?!w~oHkWpNuFhR` z10@E{gtrmDR_{g)(#zR^J7d6R`Rnd^6V}HgE_lzIJ0OnT$pnZ2LeKg`iI(|Yas0`n@5Kr5Ig zp1-p@chi-=N_Dj0T~978I#YDlX&1e{lbM1$uQJu!H5}Jl7RR4LDKNo4&9v}JeV;DX zJ`kfR!VNvwR$la(zNSsr0F72|p z9J63e(vo~B^EboTyTv|KP%EzCE>n$!2R&bEQobqr$5@v^c+>pl6t5F z3M@gu*n@xa8m#nhm^~PhtU9k$EQi-LHrD>~WxC=$ot=Fl2YEOj){kVpXJ0=2xb&M9RQS=msf(|%D0v~kNymnMjc}HO=5#c`!sgkET(Ydnr3k)f)se^{&+B)f7 zT~(CT``phOGqLJMdXn398X;pkAuV%|Q@3oeBz|4{CgB5AbSZwlgKm!w9+!!WVEO_M z0KIF;*85DdKq__lMr`+kn&gN~!vT0_bk1-;tSDAbYBgJp5ZtD zEJkV*c(ONjl^6#n?Twm?r(j$Gny*E*;bv%{Mo;hnqYNB*Kg&ekR$;h0@o2v21dEiG zF%&nq`<2T~!=@48uzcdPk9p{J{1#{yy`)yrXfVJ|fEV}fWR(1Q-y;j9os_-`w*5SK zn6Y<~Y?y<$wNKL}`6XDwL8_*pQ?%!nDW?0@agnkR_Ij(6%Dp`L%=nESrO~7tid~op_#gj^OGRC zpQ7(5X@9w>uXU$q16Pp|X0bXD>p!ubZ(4Sofxi^GZ<9tx7-5-`!lS?W%ypeb*=W4M zPf?lUP#^+Qqn_jYY<~q6?B+_XD9tJOFm2;AQL1$IioDI~F+pdr|LB02+zuoIS;EnrXouIpATp1@)T{&FS25SW1H6FiRa?$$)o-EmX| z+O_*==eB@SmFGpAkB0V$wZ8JivjKd0_k=-Dmh@Lgw*tp|<9fy23t7~YEEW??Usa7m zPj0|+w`7v}Qpz+fM|!yw=xT80*#`HPnYwMA6m2{>X*+utLpj%W90>#0+G)!$#pS#G zNBc`O8StHAWN{kUoZW0A0;Sfu-4jmt{D~`%n$!x>n+VnhtDOuwA&MD3o=2iN!3 z$f~y@6^5U9cLuw~D!Io{yt@&Go;Wz~Qgy?6m`=%DIqd^Z<5NNdvmjSa4pA;`hMF4l z4T!G-W%jb`2Q`FF?>eWxYmYx896MtqRXhpWJ^FMbftHzP>#lCdjYW|{&(1;?7}mD- z-E93}y3iL6{5gr(P@|_VG4h~mdl5r`ROn6YiIb5*=hW!*+`1+ysAW3%QYG;<@FMVz zY*khH)I&HaFt-HA-6z7+$ZFpIt#}_FDn#*3ofnS=le)Ke>^dx4kDfboO-qp&P6?Z|ycIxj)3d~;MA`9Dey{^Oas?5A)1qy^1q?(6iiwb|_c z;&B?~xz(>gD9zy_wgYN4T?IX_mlIoP6C9dHw^Karr18)#o?-r10pNDBhIg-=JTH}v zgPgwJKSV*A29iIIC~=_si72eGn}kQZ7)o-22U>6ZU9$R^q!Nj~0*XMky*kyNiPm{uq#jc8j1ktD{3-W|n0R>V zk`-3ulFJsv%z1S?V0sH^KUrNuAbm9 z{E1dLsywCJHFIU$WvG1a67dn3I87z-BAqvc`gha0(AMe2D~TH!pS7<6&@3s1xx3VP zV2k;RGm>)H)}Ju*xP_a!qZul#UsWcy==gW#Bj@gB? z-}|!Qk;M$Oq#ipG{B1C&u8V==4><963yOno@aSUq7C&r@gv|YF=9sd|Pya0KHCS^$ z4n;XHOlm7#n5q+2N@>n{C}3c1DnaRtxq%(K1?|ggTo%?^d*1`N(xmWaYLE+QCx096`bpkE&>=P-eX9r`&drB9E}pVo!wRToGr{$Xbl;VqDD;!m&sL7=s` zr`-#n<|T~y*(q- zAqBZ)_xTjmM#MDzcDF2r{D7GKUmvo~+P`|-q=Mq|v9(-mph<`Ds*ltHbH2ZNixi8D zyye5LM_1B$N(Sx`T9nNlTphP-zgck4kn1`Su&}-c^{3Pa*d@&?nLmFsw^BxC@$3q+4%-z)2~j zDb^6a+qkq1GCGgPz$8rVcde43l+nzlq*;{VHB;hU%7Mlps|9Uimr)Ekqk_SbK%vKU z1>moCf3c5BDz#!e%c+M^=01J+?8UQR)K@z1^*0hyj7CHqY_@=o6QKKUN*p6ea~MBe zzw808;>mDGXK_9kyD3*DP?=MN;$nNWJX(_Pahd?>Q|XBMv1j zJ2obUd1$Y%hbiPSK`G6im+>13u;L1#PY_uthJM`q}vkZLh(mAk7~u&o1nDX(?MLui>@ zw-cCMS1}qRMM8Y&A@()FN_rQ~MbF7Q=3p{-_3LS+2hrq2vMszQrcF_f>rSjTiOE3S zEbk0S2H@{Bx*ylKZnrPPGmz56Gz8bc4vRGD1B3RrmP<%LM^9FL|emc7p$f@V{NPS?!3`o4+scB-9+p;?5=|k@hF6Eqce@hWQEQ*|QZZS=q zCG21=R(Vx~iu~BS4K?fA&sIrSM)2m$T!c&7~`Wggq)1P6j!l}%a%N;tct|) z#ZY1%0|twOR}Ndf;7a8b!I?Y}U%0pZd3Nvi+zzGCWHzIjgDjU|S4wSEQk12-uqtVe zYBv5^_l;-`a%tXyWWw(U#h+JZV{f(Vs!~Y=uZ(DJ;ksr&R#XwX{B9xB`gH&A843%9 zcXhXc8k)KNL+>=aY^3ic6}|JmaVLeEOw`k{7i8eg1Z<|H{AJcFF=!QO>08f8<5>^d*t|@{c#{q8P z2my3S=z_uulRirqWgPeXn(Fz|5qB$vX;qjP&!n4YIq96rSe7kggUKI~>(X{8hyor{ z!UC)BZ@l3VK&XYR+OZbBR2s6FOhQ`)Rdg?!vtC*-XW^uAVBS7Q-`b65D&wF;W_OI>ek|vpq zU+Ij>p(AQSU8C-xh?r37tnK)LfW!Taq9PBj-9r4zh{9GM-f4r}Jv$lG%JppCj2w7` zL~TQeQ0q994VDD44H2`Fq-_}=)2YLcZUaXazb*)ZD@0qqzkr*3OD#cW27aD!ta)9S z6`djb-ac;Mc4x(-dMKg1Xcx+BRw|HP{PnDVepKiBt!?hPbg$PoVq1)~V~X0>!tiRO zlKb`>z8c3&Jlt+@mA;6^`%^GBP?YHHDNZ8|HPqOx#u0dZoJ$YBIzQ$Gvh~u<3 z8-w8QS}e0MZ4((Llrw*~@lnrWD=l{h=`!>bb#Iz?Z?l(2l}w|XAj6D~&*v*@kMlD?kMxw&U!c#(RrRRe#lHU5=&3{4vhuoZ`}-Jhk!0j&Rod;)4AGT{F7>D@ov`9Cai4tn`?9$EAQ9CP>Pjy zxkO4Cn-bZ-#BTJtHrE8LhcK8oqrHQ;H_OTXYgqtA=L;1`XGA{4fWR$Wh!)Kdniqo0 zu!(4!t>5)d7T0-i{CtuKzJ_1VyJ7f7ISbpu3 z?&^Gdi>YKQTex!2GwO8Xo!Kyw|Ly%C(e?_!gZ+bOJLN3`Nc8h3O742w&QhnGrKN4{ z;~%2(7GfpU`KZ@edo}IXI8=(^>o-&B2xSBw%3F#XJ)ell@U=px2}11FTb0^7%92;d zb9fk$c2TNa(V!VoLbJ#4>%RQFixHY^4Gnm!iy}O)DCXzt->NUJB!L z&cD*6z?c;A6?EIvPVKp_y6WVBFirR7khrpzstz^|rit%fY2xS?ajJ{jwryW7^?f~m zT=v`&&0am65_DE3Y{@3t+fdLnK6!WEW@SSdn1^tnBmJfM9s<2r(WE(j=pUAxC4;^) zG0s$EE|wiWskgQNCWOD5zEhfczkV*ZL{|#K7Mgs5S!U~1;u3f~82HE*GBTUW%)Zj0 z7cySBa8#&OS?$_{u2fxo^*@=J^C%t6*o(5@eX#VWv=^9xBDv{Ua(pDJ=VY;ERfS^p zc4%jWT0Z1kvWqta#F5&h7Luw0{k+fPL|`>yaM{T1r^>GMC4WBM{49E&%3v_)tadA{ zR9-(j#4{Zp*RqgG7Gb$ryu3T@r~0nEwS*TaCtGMGkL-Y)?ucU}T!*r$65W7)?9n#y zQXZjRgrzNPWvLZyI_dm+AF~X1`f6*LsYYi|Cjjrop>H{HYe zs9m$%Y2xPDA|!}qji9M>FRo`gi=5N*X>lI!&v|z=KcTI62~*XSj2E%~y_QgZ$1A>M zG}Odge!f!f4V|IxwUF`0qskf)&L!J(nbcYRsn1=weGaZ_iH;LyOI&1wiX!~0s+qdVch~SZP^^>dMsZVMH;nqstlyLmpXh~7)X_H z&i3k_9e;SPO_iN&x)N4hu4B&U+c}eu8}M<;Gcg7dX(WWtHC8bCIH2m=ajr{<)~(q5 zG|e-M)7R$~PGi6HnzQ>5(jDAgw%pzr8fcxawpE;xF=Kv@#QGe#qt4Y>27m9~rzz** zhf0^R&Wyi-6b{jOrX=1($rW3*cx_mjM(7SKq-8#da}KxAAjHLhmUXxbqs|o}Dh%UQ zA9*kDl}yfNTlQLcBbiYtkIy$zAQqR8Da5Nb-HZUrbBnKR(TvhDHL-1+D1#>|7H8>vuGWJws`u8E8;F14H8oUg3o)} zNS}3EvUz&Y1sSAEq9+HKy8$#YobXW6j(g+^?yYE)O`i(SOj+_aH4^IJUUhA0T|qWm-9V=tMzt4~efRl8l9(yFW9bry=n zVvl1K53@I$yt&CWfK6?69%B}x=lHW&FMb*5n;-MuZbv!C(RiAdW{li4E#xQ-0i#pj z5_+r_7Bz3bdMLZ8_p-;W8GV7b`!k?rx@p z%5jUgpB$GCqBSy>)LF3N0`!QosMUu%A#+TclJP&%7N(692=5cw*ZT}po zO>;PBiC!8jg>aeup|j4EuhUJyBNbe(Y~DKZ*1%YC*|(bakm7MiIaZa;gqQHM(T9Au zRaVk-P1W&AhDO4J)AgW+#>xxJ5gQ;`tnqL%eqf16XQDuiA<)@`Ww2% zW*V}_8Cf7(UK5z5s8PN*=qO#mKhhQPZW2?8kd z!cc80XtZgbC~cgL@@dGh;mM$`WdDe~8rE7sic_FFX@9&_LuR%09vLj-LM1O2rTLH( zCFb!P_FvAo(x;GyEUsv`Xc^{oGIN`0YDkz1=_hEGe1JYw<|^g3JXvJ1I~Qs=P6HkI zt3F)}LfUq+bCJ&^P?tdyHMm(2e6hsY1C)2Sc4^RRs;nYrR} z6+haJ7P9ALb|FX2FY~Fj(l+a*FoMSuy5T{5jvw@R>`UpvJpJe>xCrqha^Xy)*OFYjW%MFXpF|`^STp&ga(YC?|u-aB&K2{lqu7-pf%>jMPH<@jfuTRvMm2H}Das}-~)vXzw5KhMUN#zW6sfF*5~O+UL# zyASBBrv0e)NR<#@f0tlXHG3bLvGRzW=GuF3Oe4@6_I@6iP4W$25wU=J{V7(=prpH6 z3s`v=^r*ga+gfgDuE$3hU;3e?Rf`zdwVt(fvfH*T}pFOk{Wbc)I(DXg5FQfjd z&cr;%xy>X_QiFEVIB$Gwqoj>;ODZ*^Y@&?aRgnkstG<8O>;6P&Z#XAqK92==S!lI_ z3a8f0(>XIE^GBn}qh8yX6n)FaM(_rTW$U$tQoP)bX@bK1}S>+RKveHP> zUlim2(e>U@O{QJan@U zJMP)}4;1q6ljK;39&#tRdx!F^?ks|j$63c?85J187&7TxmNi=?wSV> zHGbr-_tNZs{aN5V$IMw}MRXXS8tIt!@0LluoOCyLE_#^EcpYyS3nFZR`z!u_;&$yi z(nib|Ic{U>tJ{5Es}qBTEX5V$a?&IA*!i8k{d_+b2(LXur+TaXHCE;72HkqO>$SQ{ z%t%*sqj_KE1+RD@cajTmFOL7|d-pVK3mM@JK7D1L6{bsA%otOwrZ;fscC@;&h) zzGPX0g1GwFDA{N>IX~=a0TY|NHu#c&x!p~2w0#e8GGij^hGDztjB~6HWnX#n19#)d zOY6k38Grlj8~`Wnt|eCj z=^Y!hP2Gm7j3+x|Ckm&duNnkCCqD%!nLwKl=u%%iov)YS@_!hfPVO@Lmm`<7Wt`p+ zmMw!`X_@G_c`fz$vuLE9?C};13FKV_8nit<>TS+vz!CMkJ;g*4qi2ZE1l_#}9-T{E z{y2Q_=<3#;uX*w;gAu}!ERHPt7_T9PXTG?;69DCz-RYbcs4CW<$C;;e{!fp)M?X)Y zH#etZn|u&i+~&cPPX#j_nH`nwv>Oje5M2Swrx09VWm5|R;L%$5>8%I8 zIC*vtkam;LCE;;xfMiouGq#OR&0wKc8ydO9++F=ODf0#^E3Z*~0KR`bP8(%)rAZEN zg5*xFABJcNu*#~nY-}xv4iQVOtWF=z-$&k~)_c~M)~!vwJSu=F5)Cw;MEQqWaW<)Z z0sy5)oJb4tk5md_NSF?q3VXR_wnbefcDV!_9#+DN* zd1O{{uo2{%u+an#+ve?1kER_v*5gzthwZrEhP4vsfSU*m{;=Bh1T9Ex+KWFN%fmBj-T* zqrcWT%WN2}!n-?yoH3Aj3AA2TE`Joixt*g{iIUrhKDY91Ma4b_td86w7WAFtwm|fe zG>2wleRk~9xq$a?g_+U9M{%ixGC2(*vndySOEx9H2c^kD#a*3^&)FTA#EOyEw^j(@ z!DR;S5<9WhtHJ3++X?C6{HD9st&gl*5clZrSY_(ZV6VP{|HXUn{%GQvFq?S6IiWKn8hH=v1gv z_ZbXkmLJ5!UgS*qP5YS?1~RRgY;0^i-PDR^lqX?|^mX&#p8!&mA08 z)aji3oDUl&%%oPz=+y)D>JFvaMamiGSyGczun?R5xz79XMm}@23-zK_=&wI2&ag(! z$t$hdMCagmOJq=*c5ycRzF1NekV;sq>SQFFCfk={5TdtE=J{sSJO~*WEj~GHjb@Q& zsXVYB+el46zSZMcT1RJ=8<``Af^S@b7SnSN4`lE=i=3@C*M1q%8?r8b22~S515eWH z3!2^*2|JlUbOA2uGK2!lHyUtx_3JoKL+ zm5&X z;-s!S+Q4|nGC{wYd-gVNjTfzHNv`{o*kdc-yJ{KxMxqcgvgi4Afuz;$uyl%R+v56% zRGRs4f7%UG!uf2lfHr|zeq&w7lj5-xj5##Bu-W+}scqO63GwX?LxABv>2IU=%Y3Mx zGB{i~A3W|M2K2X=RKqlioX4IGRasZCvt;n1$~`|LKaqwAGUuqH$%mdDY{W!S_s^nZ zj*dKEW!OXt9IIZ8hh1F02Bz?pBrt>|wZ_ju5<{#mYzlz+xqPi1cHS5`yOkswN)G4~ z#fxzo8MW$;3zsg>$|NnGn&@qkP8u68A*XgXtu-R3v@%jP{2dnAW*Az zEdn!?xDFp4D-@OM;N5R&WA!3Q3s=5ZLaqLyH;&~U>o9(VcCln^oR!`^{iaSo^ly4&3SFEZS>I@kk}_w`-1luDzVeb1XPNje52)GqqL_Q`#dJyN;n1%iP8 zjC2>hI7(nW(S3ep_2Q6LQ45y1e(qI)q?79%>S^ueAAaq< z{{^-uul>rSD@CC$K4F~KJGIp%iRazOQ3P98qiRbXZ~6lrMVQY=>w$8@W-oU-S;x5> z7NurMRpF|MIoPlFTVLclB^+h^Sj-*dOycB)RmeD+Fw&=z_TxTL=#I9Fq;O5kU_H%j$VF_pS{x7U~(Q3`CEJ2 z&zoxMi{~gw*(eZ{u(`cd?pbO&l0)IC(sx`S8oAr% z<_^%CGA|7tHZE+xrasW_%)2m2NUiF#gSXsB0Qgi1%!!Tk+dv^+cQxNr?B`W@1#^t} zQDT1-2__>ddRV?3273AkI=+0Um1Qos3HxzA$Hh4b`;P~kQ%w7>>IX~6~k%zAmq zQX)!~=8{zRvh^|Y@|#tbmrfxmHZk}0N#E{>4Ry36zh1VaZ*8(qetdYzYqV?x&o40T zbpoi$tD^Tr#zrliovRfM3Jr~3ZW%uRx4G7F+qM*5|+Eo!`bg2Vg>+*PE%1s83_JsFrj@3ar126Y3 z!(nNK{AhQ&C1rey$;4w4(pCUMn%@sX+HL!vk80c$wmZ}jJn{)iP6Xz2<;55#*o?T4 z_gM@oKWHS#|C&yIhShH}$ef3jO;aV>^J0EYVz%y2-BHMw3p^C~kYjmc>9b**4uxSo zOiXIA!C=JAi?O!W)sl48ju6IDK3k!7HF)gqR_6srx&DB4r&gKPk5Y>%UHCrMs-~L@ z)%;(cHHEu<$dQH{Yc<``Z$u(Be3Lo?-wIB8a8A@nmfjHpBO?-@t?aCC zly;O3?$z1&_xeTddg}2sufI`h-me{exJw?-vT9Vqn5KRPo!2}t06Matkgc}6E`e_k z#F{ia0`;(R{A6}9tM`;r(+|h#6%2nPxJ!Mrxoe$w=C0@2<_pn-`TFQaCAQZsW!67L z7GA$GIQ=v5U*shQ!uq^gUW38d(dQ_9EossHi$>Ah{61RXS;PyY?Bn zL}}uk=Aevox>3f9mwu4Jt7}Tar|v26hW6I+OOuvST>fej~}B7|XVJ zN4I)8Eq_#|QEn&RY~|VC!30Stu?vz7Dr-8IDJH(IH`}^<&$Yo0li|JADui}vD1Bjw z-kGo>xqsX`C+QrXm7#p|VNLz^zzC4W{cjkS1j2BRC~f-BpJ*yTm<4;mS6k2o9fI74 zo;qZcqW~fzcJ(RV`1E{^G1upPqt#j!^lRO$jK|fm8W0j7nkNEUDY-6)QCq^?<=FcKm#g>FiAX>|nNA$7PrwifYwV zgL09?|2WA}%g{uK#-m2&q-6~E;0l^>zmI!}r$e!lhr1#?VM6jboG(XCA~;ww)ql6! zrG$&(GNG7zj}xJDLF-LAjSC$tLlc>amvqn3TW2pcN(5TbISXh@!1LbZ^5|U5oc7#6 zZ7(cs4SvWAgY-j{osd>;JJU=X{M=~TxR@GHS>riPEXs4!TH35zzN3vcOlJ=CMD*Z+ zXhH)1Q$XqVzYBRhu=D)LO+hY~@h0^PA+zO#wQNaX=!GWfU+e+$of*6rytC@CLzkMbZZBb_1R^E9fh ztmCq2RqU4#&7fGaL2Whs@`i46a%Q`YUaw?P&rI%Wa00}!r^IMgRLr(JEQ3weoMRv6 z0jkmK!`uR9*-b5;xUlk@4VLG>}$F>U=jSb+CtzjcGNwn88$}FpXf-&=Ice?lN+2$&* z6@y~Gi90fwdQKo&3H%CUrhdymvKJr+`Q_iEDUU8Nrz6t_!*pF-H_e*0ONBN2Zj4_v z8Q6CR*8FN8Jhhg&?D%rs3nRkgTb{*roFr2CCne72^5?JWj~~!flFB8xL0e*A(!kwt z%vK%c=Dk3XLvNXS^s2hK4^8QeZ1hKfu6NT~cA7{ZNSBsig`x(Lh4=j(tuxoY{ps8+OB^Rg~(Yf^%cyn%>p2#L{5EG~7Jb1)1W9MVb;!6AYqd{mlt-q&FA~-j4XZgNMGTVvt3U+cSpY7^ z%aQbKh!55a=;bpNcmDf4ulVXTc?eutO6k_e$8-|Zqb5Ho)2kGiYTv^Eo64dg?ige@ z>D-g6{nlcLEbO|YN5xRQ_(*L>GPG1A#rrHGn6M*mP?1GbxWDsLK4*>DcM)7LN2lZDyB zga-R63!*dz-`jM|$&2l%V1v2O>=P0)DqrL%b9e-yM9eu5h*xAldt~Mmxmq-~3riZ} z$F-MSZ>IV>ZSHAD&wqBmmZdYdv-o@v44Y*(`bk>!B#u8su< zpJ6T5VwP_CBhEtjLT0VSOWxdeYrG!R$cCz#eP;Q@e-5uj=P;=j>Yf%z5hKq-FX07N zWs7B7y3CVwekHD#BU3UVcFh$4F#q@9rouHHHDI{b@Qn$u$Lhb$5cpK_oY^cbW=0Ny z%lkb1pnx;(*=stDEyzpmoD*LRWltj(Ug^y9hspw@NOHwp`hEEsXD}x}SK)uLppktg~G`~%LT=kWYI=Y536 zZ(`b}XT|&EzK|lXn{c^|Sfx6mkxAfkR)wVxc6FMo9GFVT;1jTP;^B*h5w|9oY|5PK z3jyJC(P5>5z^W(kJ7chwsU7WcZ!^b@^1ixz9cOsZZiNcI5GNuOTnj#AYNQF-Pz8dM+EQBfwmU!g_JH|N>WIk zn3#NUxMuS{&X>W3wRP;mazScM zYR3VvOr#}wGu-RFG6x5}8%=rZd%0HLDAmHeCah>o{0~u{9MjOJ^agiy(yy zwW*_{PW#gwLpH4uc6frQj8?C2P1SsGTq+@@vei0v54wDsn}YZ2P%$JHLJL4M!i7V!r+#}(jW(Y zVX2-!1(g2xtp=1k$Y~L>&T(g%+~0qk3Sb4%kwGVyp+<8sgr3A`R5Vv&fipvL8+Uk|tXI?4(nazf; z%X}kuI}HYI%oC@iA6v&+Xti^0ZM5ggbJoBN3{x$f)zH6>&c06L1%R1a|2Z7ONmaY# zMCDD6D$NVMzOfvFueIjnb!lpFX|2w~p}TK{6vo0{YR>_xKo7nOrdTw|CeQ0urWGZD z$gg6Rb+tGpCEcqL9v=k&8eT~A0ppGDFd;Ed?`}4 z3U(v6+n)Ki`0w+`hZ&OJmQNwZQX2`b2ufsUb=nMoWAZq6w_B&{03(VEajxOlbgbF^ zBHtj?u-O>3q)e#_!$>(~di)sJq(OJk1;fTtvAMYP4%IO@oX}Hn{fpD#qm}6l#Q*}E zL)2VsNMpG6=mlPw?6TCVRBu`8)pcMG9TUW&+nb!I=(6w$Jtv;X=Q!K+@batfmpOr1 zV9t>Au*`-T_Qw*4z+7?Nhp-&8EVUV{$ z#%!K7w><*T$VhQ1;fe+DB>dIfUH{9=7 zo#dhGQGm24R%P23`?aKGf?z*l{4@ao~xya6{jEP!&rFF%E zYor3JYnaIEq#XCPTsjX!+cL=Wa4ngyaFIN_?RqCvJd2Qr7)bAe+^7SBwR71ZH_wS|Y z%t-+<4`uv*0Kv(n>_RpsS0Wae>r?|vOK`vQ8qQ`j??2l1th+}ZLIC8{z-Q#2h+QhP zST-&?;j6SoQ^BNGyKr+~5PF@K<6f3632Z0>s$Cgp2UZQA)q=jz@xU(LsGh3!ZbH}o zwWJc6^<0mhB-3cHO4(wEnF=7jahzW??J3Me+;vbR<5|W9f_>i9z5IC=Mjd^VN8x?8 zfG1x^(o*uM1P_6q0ZaISntBfO1!W(|Z1JxU{P;S|9nS8rP)qx9MDYSzr#gcWlCMQM$~<20CAf9pYCO zzN^!AQ~;b>Ehy*SVd?E)Y*5;6;>X!*az@OZn80tz@7Jh-mSk1&NJ5I#F}|zuj6yL$ zx%6zVO5rP|OSFr(ak)i{PAf780A8y?`a;&k7EX24zb~S^k7oax3OG7}p{$I*OWuX* zU^KpCI*Q)6HuAY~tSlpNDzHk?A$Ok0XJkcsvrl@pTY;t$;t(S=1O$-X;t0swoa6&s zD+U;}E#&f$7X~Wc>wHeYE&b4%FU_5FezMfLM zC-gGsN)oogttbm=elrP!sXs5I=Mp*LRBG#DW?vo+w-l@CvK_tB7XG#Mx((7%Rm>ul zb#3Zuk5d8G#>AH4Fb|;4roq(FI%y||F&PgQuQloPQF=NsA4oy7UvJ6%ur9g23Y~<# zI<-#^LVlE;_j&?l}x`Svqstn zEql>#Uc@*hMapDwk$LB@Y4b(qJUSDoEk5e?ZvZm&ys)T7#WE?-?C@QKUYB>}Y}(;4 z9;LiZbM;~zj07~+)#Vyb9?V2l!Fn2K2n%||NHgy(#=#d)X;@I+H0UU1)uy+Jr>lx5 zt-|rM=}YDy**{M5_1d@-M|$0vWo~;avo;Krx(aqv2zm z?$p~(r&|f@B4!n_BfXKnh8x6IzeITmxgz57xsGUL_c(-BoU@k{klB60a*5SWQH-AI z2$h<=-gZny{!%t~w7v{jBFM^GF{7g8d;t5Vv*TENqEee&@JvoUaD^+~tqsdT*oArFJnhxa`|y`o3;v40*Im}Zho6t;uMt}S zdCkgaS#y?kwJMpcR`WXf6uUx)S}kL$x6kY$Ah9XaASa!84p@t@RX6O0KGmyO)N*8bf>Yo0ztV1t=`pBha}E{RymTn?@02I)EKnkTl3RxN?HBUpj5x!^#BT*|s-^LVRNmr> z^-ckhIjC4W^CF!+>xa^w>#a)T5ckFY78?2}gr*|6@zqOzRf9ME-OyE> zyQlY;M;fAGEKkJh#n*BSO5_LnC#XRF^LekXci7|o7?Au7U5Gkw@pj3x4_Z_$gUHby zJs0ETbPCD9r5xBn-+?7c^qS1dqqLg_<{Y)F@7hI6c(#=pUFX7V&HzoFsRczZWmpj= zh2LyqT}F)H9s95}<>qbi9v@$}MOT+G^oj;UGln?!zLYhSz!Mt6v+u=>6Z6jx-^3c3 z?-G{`wXkcf^F;D~ta(Y7Iagsr`RT2UMO;)UbpXhg-n z!Tt58J6s50fc_+D^!x+RppiAPq)rVQ`G#O^`ff;C_o}6xLsOqwiIpgYKaB!N7UUA9JWxmBi zm=_;!{>1r-KQ#~UPYYeorxXisir;kzvYi~s&g79d^@#z)IHw(*uI%jDY?%Ws zsGeY^=oO%~5oriib!@WWAF3Pw?IJ!|FoCdMy`+g}#MB}`z&jx zfE#JyvPygSo2*~cUBl$FCu36p-Z>v7J|OluC7x1ip}4yA;JdZN?PU~|{$M@zi$^*< z-i$wWW5J??a~ze9l5vg6z6MC^=Cx)jWuMNUeuit0{{NfKexC6;MXq&{I0UQ|jeq30 z@$qh{^uUZf$npj()JBU}IyK&1a2EXmJKaAy$B&ZLH+pi~`0Q+#(Qwo2^?CdfPFD^J z^yjpRNRymwW&BuEGqh%9MNBT^#*!_T15gk^ciu4ZU;`fcW>PH#8mUX1>_{q_bkyqg z`$|(Mxvlt_O&C^J$!A*(<{xgoQ3_g!`+af=N#PNtgK{pH=Z^~f01}z0&^3cd*`L}Z z3)3c9Ed6nt0;vq5P5z$Z%}}oCyfdJbFaL@2K#5p*<&BijOj+KU;uHuV2dyL=sha@c zKX24Dc%(DXvgbwn6a-9#Duygj^%4@wLd0MMTRkkZo6Vf_c6PBrODqd(dKo{=b&0xC z=Q&j>d&xuSUP8_QuoyG%`m&)cfl#6XKY27?`?i^OstoySFE<^w|E=L0_F5EjeYUCh z6#=sluj1s>_PNo>~k+O}|d)&sz2b`CPc>4?OgA@6m1Z%47dYM#>! zOntwv#mdrmESP&$G)Q^dYz)06K*gWmEc)9lWrOOyGYMU zK;AU&x0qwKxuUq#)mH9lQCA_;qiBDVa4GdE6qwGDF#(_-Z&e+8?Juyd@8JqRGt0Ce zW_gPvXtk6TF%?icHKm)X*g%zLrYq+k?w1}7z+|fN#J-qkgIz@Jx$tJ&(`cvVhP^^Zx>D^ zkLUQG0&;J_AU|zj1+%$kG8ZMunL_yOXN6$@RokeIM2*r1&JwqI;;OgucPm0Bb4lE1 z8#k-1Px|MvFD9K`DYE!~5nHv>`PG3|$e%@i#~;CFwv&CYT=2H?-krfY#JEi)uwxul zV)X?^)8MLDdzcTjXVk;f^7y6=lV$b3-*?}BX7$@?to8-gvylLiXX?-JP7A$Nm-4r> zrb%?<5QU~F5%9^PgM?qz54c&?wc^I7erVwKN@-LFgx^`M~Wh1uUs~K^# z5(o@(Rz=1uS$`nAwNJqRH;W~+z zpv69opq&6kp}pcxCjaV)T`wsZH4z_BwCP}kU5nztQ(h4>^1WFpK>TU2@M_!vv{8CX zdw4o7jh&HF9KmM-1biaB(tRi$c8 z@A{=9=@J^i$9Lko=;K-(Mt-)}5BfG|{10{669dQezLn~N zo_-Sz!tA<>F9W+Raypqa3Ba<;NQ2NcfEo5HK&1c2`82M|3_qv2TpAeCF1vR^Q+qG~ zTiOFydjFxsP7x4T%KNwg#I2mYI^js!XgMA7@4z+#ey)=jclzh8qztF)kc|G69$R>7 zmp)jad*~`Txhoc1qP5!5vtPh+oAVWSTs{k6qU;31W-+NkpeAH(=^lKZSQ0y!&h(@O zOk~ZY3Y$|M_t|CqX{eil3hFt|b%BT%F&%zDoP;)i60PC=y?RiaLuj{60xC@OFnw4M zi0QPUJ(SIq4Sb=V1+fzf0#!+;1Y+-r7i40elKzJakl`^ExKFY+L5UeuuSr>-Fd#BQ z{2xHa{jz2aZs-*@>nP=Ogq56(HJ&a=_o)xJ@>p?nz~{dno+x8pw1_ADY!dV32ch6 zC&eSouK_6VX6Ifomc1@iH7{ojXQ9hq7E}DBxnp;AH|(IvA_z{x_*wfr6$SdJOeP=P z@REzZ`PlbKbQ8H3vT&#LL(>P;)%oSXwhxYf? z*hr|r`Sz%KlcujWJ+|Wc5Tc?7hS~2k^=qjrvcBWGjSi~Yx)*|7!~5I^@lpv1KDs~3 z_#@pJBzawQU!&#LZXJB4$jpKMqN9ZH3J~1$v-Rs>c=)#vePK}Iw|h9Yfcu#rUUc`i z0?W?%eM-aAdKcdqjP)=4`Lj55iCM$U6fl@o^K+bp}WK&cG-ie=?8`tu%V z{o2FO8I$tQg6e7fLCLsiS;4~!uJbp6Su+}YSECzc_N$sVk6H8iYxn1OIxJmiY`)yy zDBQW*=ip*V-YFf|2bPbnKQkO3!OX~qxxfK9QnqPTpJowgD4#D;~4 z0RSeyBvAt4&}G@#p7K6c(LMTdSs^pF2kn@w_)~S+3dK3JVwCaKDADz+RRuNw@t>2= z9lTX{&+Kaj8e`WWHyf9hn80sUZ)bS^$fEy#$STfx%Qqo&w$q=#;m-a+NvrDI-zzBq zRTbP{T<+JH{0&*VU5{UtbI4ndElBWg3av za_LIW+sG_&D+k`0tV37KK5IO+g>I-&Ff~R-5W!MX`6q z1KpgK4(|dgY)-))4448lUdp-u8s^;!hCuwZHF@*BlqfK|r||2Dt&U zZ67Bpy(bl&63uCe* z>`_{-brdvYGXge@PXi+J-(5ooGa&Umun=y%r$$ji0QwbS>Y@`TW9N?DpO?VI>L_D` zcl)I!5xHW=5hupITOsLiZiHX`c%AlA?EJCF%c>v0(e9m;7SrukbYFja&PG2whu>3L zZJpoK@f}K~)C}vuhu18`iaX!G$-^dl{xV4LLa*Jjnw#`-m>!1J0dB?kZtK5hR8m2k zUgBzA>8C3zyl=7)kvnMkE)8g7sMC#U87{!{d`n9d8pxl?T*8rAPqroNNwjS2hKu7^ z(GMk9ix4pzcJw_ob+9GFX$L&-YvV8@Nh;1B`tHQ+;wNZ)LEUGOKufAQt}>y&`j#S@$FBG|`m(3hh@d1t zmnC&)O@-vq7XepJxj)lpf{#w>5XPbN=B_u#zY0ID#hV$ zf(ieroKj-oR`CAQkiK6lmhTjRGoE0-m6ly!@CUH$dS*jPP4nliJxnaniAuAT38dL~ zHC9jM!;K+)&4%sg03kqEpQTfQURnj^0B7!hp<2IoprWpRr!n$CNsz}%U&_}LDZ=B% zX&QsEY2n2vq{M3~eJc<4)U~}}8EIF(SDNrOKCYo%WT-ZPRB#2??_c6ZYPVA4LQ+EW zaQ*aH&Inm#5k6{CR4fDm?x-})3TvpdF=IHGjNw}vdaECsY4*eN55PdvV_y) zGr6YY37Y0L0?m4<-%A6wuAKRaeYo-8w+lFW5y2R|JujSCyG(yEewVRxRV zLn^^JPr`6V#@Oep)5-9Z*3s@xCzG9~u)FtVyePR~i6j;TCVV>ernUs@PA!5Ln~=%D zi|;I+n2qLljFWO*EHc&4w(*ABo@CrYjy?E*<;9p@@~+RHBVOS$#T6KsTos(VTAP?^ z>Wo<*>mPkDuCV`0iG$#6+xEZ^*BUF@isC3y`k}|3-<0F-gLzA{_oe@-MJuGA-f8A= zF?#0}4n>twL*-fYgXkb`GlF5Z7KaIF)&?Hz>m6lWGqgl#B+cdFYnOEu7Fh18H;X7F z&pv7Ep!DP!m_Pl!thwW^s+QW=>y}QULR=1pwvHnAX;U~mQ53QL370x)QAJd`k=)=p zZW2I_N92f}`I!UC`{97%cKV*t8QQ?A0Z~J#p3b)CI=(eB^JA#6$h@vfU@sZFQKcQ% zy8Ut18zE_ehB@634^Z@OEYfDD8Ef<5`#WAt^r(!%HrzZKqB75!0F?lXfx5y|0}UmF znw=y^9_TpCw#LgQOc{W>euZIdY@q^*wxqzIY4Ac(Zgrh_Zk864s5`Mgb8M+Ex;d%J z>X^SSEqLFErpA{t!NnBxe(tIEjxsfsR3|i^&9nO%kFL|3!7Kv&A~9|yB%WI8qIaf{ zalnAL(fv0H?EtP8TY`h++$3~_>i5TbdD$ib0^fB4#y(MlZw!iAWqx#hci!OCVXMH} zAD8&iunFSNI=}YsZjP}6bk^8YAnNupgw^daimY|F&rIPXg0;D1SH~ZL;d-jvrz7Qkl#f%5Lm@dW~t~`-%NWN-&~y;^3m?6ss-|Sc2hHYh}mJl zV$Q*Lpq+52)Nk&whpN`D8EUvoan|+bV2-V%%^44$4!XK7uc#cDJG4X7!FEg?kU@a+Kty1P>%qpesei(pP12>DfFm!q+tY4klFa_F0$_*VT1W` zzQCp$(2bN;q$qEu)^Nc_ZXu1*|27?>Eo~AOKgJC?G-viZuW#+D3(KSPSyW`Xn ze0Hy+99>SgLflEYlmfuat1YrFBH3vZ@IX!1`!4z= zQtQ$AmXqhcC7@k{Y8mqGDV#4&e4uR^eE7n6kJ1c~NR%@1QNo11#cwbH|G6P=_TKGVyeJqM3?A$)yi{`6 zyk`Dz?|>g{Zs*DO@Aq_`wE~cy`UDV$HVzwS+5hs(NvVm_`BJ2R$s@DXJ+Hh+kSKGO z*!V?hp)=MurIu#OpVZA7kvDJ_vU>F}AL6njZhA3BZlvJ5K^vs{p1w6^ZG8bx?&>qk zFG@^Im$(kpk><_KQ0hKbWiBh03!0a`dw1U3KX^M5GSk6Ksvzk6(6LJCb&rw?Mn|+- zlFM$JUgNa#s!5+zSUCUnWU^waG36AoIC;@>pmhj0=4pJmC?s|YG1OR|D@5IlU45E9 zmMK(Obt>rIzn{*{?kY38`l#hHad64AF|eAb=Pgr1l~WM4s)FD|?xFHNb?({A`#9UR z1R=4M&K|!nh5SKeQ%YrFu8%yM94tJbViyPCTIhqi?AdMVaQbeDv^d_#N7snRyjf`! zvC76>{rQC5eK$ueFk!Vq;0dBD0qG;qj?Lk)#5~OOAKdADJ?M&rSY>)18aF%0nE1iq zITnnT$jPobGYQYx9znaI815Z6+{m6E`@bGRwuqSlNe+eexj=cwT}5uU1jUT+$Y><$ zby}E?z8%kzJ|WWk6hndXi2_lW*UAfJsK9)%p^A@GL~-!U1Y>2?`}8JQhPHM4HH@f)h* z{)*SXvqSCNx_cp=$uLwxglS4njegw}qBa^8 zEtfd&cWKwla7c8OkEBouuPsJE7aK>)$8^C@pP~X&&roCYgkYt6feyPJ;dOR*Ew>&S z#o#J8a(H3(MlmwK1J~@B&dwIF9`WBU*FFcn_REn^ z_7saao@O>BH)zIIWE%ec*W=%O|Ixr)65Vg)(^p%zIM_i|$RY;2qIW%T9M@9b9;Zh% z?Jm>(`^(#b?raE(^N{w)%-qDKvpjDUL*wVP&+bW$nmqE;7+Cznl@@g8f?Pu~FcZ>i z#UBDhbWc~J`p@AMB}1C?wdWM_w??0ub*am=qzRS;$fNrVH>C&elUsh*!F5tyUmlf2 z`elQoI=L&$_!sB%E#GUT6D>BsNlx8^>WDL98+Ru%O5roHrwM`T4!f)A3k zZ=ITyf#GvNedQ^L9fHuk<7{k2uj zY%SaWitq|06@`b^Z@k+6jgAT)+P<^2ODNUB+rPZrQ0#xy6tVHusK;zDd4L_*36v_M zJbGwP?VX_%Q1)Y+n2GCUNw(|snMNN`FU)C{qnj&X-uO*ps-?`7)hnG3m6wt&rXZ*9_P zr$x;!Vl@?$m7$uv#c!uRFy*QL`#7zq-39)2F*U|`-SK)4P5SHT!Mo|9W_ptFIn8gz zA#xaqVval*UVKVFaDIVw@sy{Hrf2l9rx(UW&gC{fv29PB<6Xi=6o)Z|5!;NeHk?1Qud5-w`x|o9- z4ZMS8`y5iF3Dcj5cyF71Pk=&T@=N$%&j^bO|>z$Y|Q{*C&17HM1Qn zt3&&l)^G$F;CiP2^Fu?jdsyn^HZ8el6jG}8Gkigd8vz`SmgYi(b!^EGP|E2yjdIJ< z*uCAa2j~_>XI9RA`bwKVIQaFAGuSfS=-O4jGcsv+9RKx$pD$uUf&93?!qkM=msl2( zV0iASXX>rvY~vAj?q@efXKNz&qOpnzzd!JO$F9Oo`4vNu3!f_%9j2*E#M~kfIFS!a zt?ZZ0idZ+fu7g@nWjo4vFBWZy4rmpbl~-r3_7Zi+Gm8wJx#myT< z3o9<=q|0{3u1o!@QCwBj;ghFCdv>{?L z%V+u~ zS~{P#ALJRSEvTCfUm;56Nx(JmVY{QPjZg3MR_{d|`3bc1h-q&74>eLfkv4Yc>SB%x zKm2YW#l;=KCKP#-2;(~WuH35oYs}4ZQ>yEe(g8GkdO@GFveaPtWvjsgaZ=+$p2{4F z(XdqSIzgZ6828K3l|O8HV3gQUJZ5e00QXbQE)hf$Ks*+O<{YI_m3f7=Ma^kaM)k2` zeIp$g1qK|Zm9>&MTvge_7i*R%C1-wb1+*;xaXvj)1pt=~gE1(5_jh;`VeZkp{tu5- z)UN*a;SZdyP6?Dk=R98fMnx(7pxLy#=1P$mr2AIyO@uWB);+)zn^AmBz<5{Gy2yfI z_Nv#<1_{`O`q&@q87{FwTDOyHu9Ywupm!ca`cLj-_p3K0&~}x%ibYx(mZ-xZmxPAj zuDeW^4xG}^C7d=W^xV8P=<5Jk!*i2q4Gmhrm(MYFZW{eHbNtS4OV=!mR}s20RL_4r z0Fc}tnYjQ&(>r-6VuMU?oM%m(rsOr)OD8Yk?fKfL1ds|^d!mz7LNEUoiP9%+`JQUu zqzqD-ryS&BC;F4l$U|iuBcqo;T1;Y>&X|C>Z>v=}Xa$|C_a4l^hw7FLokF9Hs`rVW zJ?nK6J}<%?tZk&3vFAoZEqLkT!Y%2i$rnapf33JmRXV7s7O|x~%)JIGw1<)V1@WW( ziG-J7gykR`A0g2Jv39L}(YcZCc(cqj{lla6Ivzzy`^n~Un~He_q6szhfHSU|>g75+ zZ!UlbTkjhqq(YkF?+?db-njl&=wI`pM}C7^9Z<+eo-5St`_z1u2VI0cwOSDMiZOWe zGTrPV$CneyQlBnz4ahe}+|U9R9cc32mLgVO?~K}5I9rXY{J^(kS<4c=0t=!y@$HU{ zQ7WoKMs}it&(=SfbBHMZVQKps5WyZ#aUy2bNW!$-DZ^|c$6(bg$Eb%p4sXM2`El}T zkYmkh#f@7UZCahwq8mP4*M$E#rfO-qz%GAO=U;!ON1xnTfi(Pm(F%L>wuNbsv^~q& zE5`-CCSSITvbldG?>kZ5Pg`aQR(QvoaoO4fjUdLpz~YBL!#EwCk#VSAysf^s1&AA@ z2HeX%LyS?P3uI{{AgLhO*-<@JSeFCSJO~;$pZ#cm9{B2uLvM2|wHxhHwl-=>CW38s zl_A3^gF#U;4zUAsoTisal06yLokj*rOXRs!4{zkRS~ZRM_L-U*KE#R&@&DuNz2n(@ z+wfssI?)+C=Rc)FwuaP*S5qwe~2rViO}VW6utIit>DW<NP52o~bTk)VF-;w+uI9=UoUJsbyNA2!q{TD9l%K&S z3I?w)RUlB?<_3Nqu5avUn6zy8IQ($#1*MUTXoY})B?ga0V`M|kiHc=Ee7?+d-QAt!Xq!=vD>2dP9B}PXaz6y>MQaaY*+;#8rR>_&&YpS-N z2LcZ>y>CQ(?~BC?-DuT)R=ttLZai7$y#yDB3G&6`@au1zR25%;P&H;${Fj{U&9XmB zHPNf)dyrw9zl|F*Jtj!Xx4+5G_zz>KPaGWSyd0^s~s zia2-*QFrUj(ukIp#T#nb&b0MX$Y$Jc%()%~zypE&SUzb{KG8ph)sttG z&GZO&)GZ+^nBylYE)T;DFm+cp=zy0 ztMamZmo5}QZ= zoG#yI)&4R@syE@6d8qD|t(8I^>L~lF^AWZ(Gv$@eOpDxDpJP)wu~#o{HvDoElDe_y z8RX^PDSqL(DrEini{Mm*V(SzVaX zT~@P_mZm0l=zj7#8%c4uCami?--`0lUw@1pZSL=3avEFVZK!dnamd>h#}G4$&AdBv z`Pv{naVc*T+r5Emhx#o9jkC7HMNi$Shy6FyZGP?JKyfqyh>w?%Q`C>&VGs5Yr_w3{ zpHY7FpMyHg(T7Y$RgnV!H7_)|9a&Lb@6Pe+^vIj5&h%EgW`pMnUp~EU`FScMI{IYV zTpg~ZVM#T>y;LU_g2=ZuwQO>K&uTiAG_=@_jh^oD&iBf>1lOm0#?#v-OR|vf76n(U z959DoqudDp@jK|5kt-%LHhf{;zSE+ki5;FW-kOqh6kq8KZ$);st|x42*TxpArN$YZ z4)}_*!xyPoqyAcx)?gI?H!uvQcvQ*7j5ddqvpW1MR`!#*226_nB} zdDJ)0@WtM4{JZDm64RT9Cmy}*Z6rqqaP7;zq&|C*WrauK?zGF7k2wh^No@ zuIx+nh}pl3tyrfOj|=o}HAYB73pTF4@`plF!OdQ=GiI~tL*n|(Fq_p|ELIlp{P0jJ! zFQU8J(4D41(P0(RH{P=UmsX|-I6ZCVPNVQjV57_bWmHs;|KR=yH`@@uuT}VDm&&gO z5Dm&S7}`UsPH@u*=Vx@OhU&C)9P50begURe@2?-h4{X63zR@Q+(I7j98s z>zm_VuIh5Xw#OU~=cCVDzs>fFb?m$++KX1CJteE%mhPEn#E{+DXlyKA>BcwjFGMJ5 z-oc0uVMip~TJk%T^?6x@;r&0ewopTROF{aKCwzG zRED~9glqM6M_*8Dvpz?eUem=3o#DBEFw1hUD*1z-R*vTD^a^;754_VK4oRIc$LA~Z z81=wD2T}X7wZ4Db4bgx1;^lxZ-(BlFC04`mxEP*^vDc|XA)N({0Ba#5fa_5aOS?Q5M z&^S@-!m}%{U(hxLRz_4QMrw{(b=~P$_EpgwIYEEmzDjdv!Q^l^r$_9#kHqL&B7Hc~ zq&e&99(M(leIWqHq#)SA4fEizzIKqX{qZ@l_{O?rr zzAbGrpO>dsA5{#Fhk7r>#$#q{!&Ld4H2lb8iW;QW>_|q}ww1wBn;Qg?{=?XjKMNlq z)Zu3#@%6~eAfGp9t<50So7@y$l_kKUCQxGI-KSHR@jW6z2)sHLR#9|gnsBE=tLimYB7|Fx`Hz3lBtijgMWiQH1P5p+#WqXCM_bXV&VM$P9DsDk zBG)CE+{WxM0*;QOH`Xn3#Zp0c3fqK~TjZ?c7Qv`qoSQ8oi8v*jo4xr>^@76htMB_y zfU8?a|DGztX?8Hxo4$~tWTCf3c|tjnS)B1Wd&0a29deO)z!d7D)l+s&|RIO z(+>jx>hp2k0%bIYvBl5`Mb@+L$OLpZURM*K{Pg9o-j&8RwX>rSD?vG~LvMX37(oE4 zsjM4rlWWNa(g_8ZbmQF69$fpoMHNMou?!>OKUQitmZry2uFiz2>H>ssko)AnRFh`5 z+0#><8;T&#qSYJ=>je?dB}H>E$Zb*9~Onebjh28`;P1>y#?yA!lxQF>Ah zpZa&D3Mf!x!aQTcSD*6x{~V`6^_EXkS!Mn~b3D_(&Mt_wo|h3h)5RTOufuHe(^`<| z0%26H1{nqG#X`MFvGI2E&Xb}Ci>r8t?#^OyU7hM`z-4rEa69elEbIvggi0`( zlG@r?Va=Mm$=l5qhV+r68Ms?Ht}-fd6m zF&njE=8!NJW_V_Hj%y8I<|zdk8SKpB>!= z23*c%Uoc;z^qLS^5^MSbH8vQFQD>v_dvbSyEvjE~9m2SuMeCE^=LpsC18rsH`9B+K zr_6h3gD#^$M#Uj$QABr2ZzZ=i#YG=~{;*sb9uw#BgPM zL&xxm=%Cn$U*#Sp_@o1ux{D8b5>om7ND!DcI4G76&RfB#2UUf_i4% zJ&yR<#$3y`t9%A#z)N?chYVL{Q;L*)vae76^4E^XOhk=`Rx4WwN@9faL~$=Nc}??q z?F*~-mxDxl;e3WFj~#$D9F)slSoypiMBJG94nzf;ZLFjX3?MhLBXOH3aEn8^1q2(H z6Y8qQ%rbbr7`4&!qC8)J=GB`pm8$*pV($PmPI=`7{?t1rNFSfq3AuYsFjR4+I%S2s zZ}M8YfBdI0Ql6@xmQ#`mr?gNQ8TR7I3jy@0=gS_)2tCzw)}lM1upIxS`J5^O@k&Bp*eoAo3Qs-(vvnb(H)4)WiuNpZ)<}C67l|^et z4T1v%K)Q6}EZTvUuLQBI@vB}DC35x^&EDg8nuZ2b@jD7s>7PA;13B z07eS!6+igfGoI+b+wo3-xSw36*H?{whz*GAcMWVtvRNX`o4f|BJrUZAWaa8%)nBVv z)sHNq6acoa*kra*o)<|jRr$!D%+*S$><_4)ILtHuY#yx2WdPi~65x&WO-e6^^Ih|3 zc1ds={=Ue|vLRi7==K>dX8&v&fcA{zj}Y?$#e^J-}^oP}Ny29}R9I${fO7 z&30%FBh#1G$4zy~ZrWR4Q*`~BUT!f1N6YEF0Zv)hX9rhRGOr$_gZ_(W4qKxC#%(ui z&jED+(#a(JnQPUZ@JV&NtK@alnE17wvn}#UhumMgb_bhb)jn2lAF55iN>jM#(JiCb ze;FCQi1$2i*AuCq0=u9lC-p2XDjk$7*ydIhdR<}#9xY(h-GkZ-?`I)5?@N6)3qCiB zXRQAsrLc=XHR{NJ5fa7elJ(wB<9 zhaE|&eVfp(f7NS2O+8&Ifra~K8#?FaC)qJsGX;zT($C=Rn)P+(cTOW% zpVEkGheV0u#)+Z(9A2{h3tANVLB1?gk*h<*4^P&A=uR0(OvFs%*4xZH`{p^D8b^N^ z+M;t*lw1oN(X;V@Y8nUYR>3${=Z4gQL`7A-mZ<&c{w^hWkXepDNwsM;W^i$*rd^g@ zty<6fP3(C=miEAdaN7eE)=BfI2)K&p|9DWS+`7vPNNTjQE91Po?f>ON*Wwfe_;%_VYR^j800bP$?>TK{en1c^N|nTvD!C;z%uHrpOgD`cYb7wR3xIm+UQ+Sg zwoSh!v%Z!CZPcr3?O)!iolDBGsVN>qw(C2pYnkVChwB@xcEZRzzvDE`TphKY?DRrs zOlI>{Zb%U7c2h^j1&)*rLnXCWi2bf9PBUv>6z75u!#CM{P|b+P|N&OrhC1FitgD5NlkV&SS~{4cF}a!cymGlzKuE8v{+2=Xra=2mYGjlc?paX&-N~P z#F5=&^gb!3tq>(jTO_~!Tgolijp9Sln8VSFf%hiaT=qaXz(E{chCI>9;9F_9{arcW zrd30FO3Q6znL^qDcmfdS+`O&}f9*=Sqf*T`9<#A7scSi#S6$lXnt(rU0?YaP!GrDX!C0n@J# zcBHesaP<(1dpT_kq*ofl^Qq^L&9#Dw*e}7imy*3S*P|2uLwpIM{kS9!GX%5*>%&yy zz9A$Wx3MEYVq1xGFtVH0@lW4}2aGn!yy6S~MD%dC4B!YT1pG_a;%~DJRr1RHDstab zde{o>7tyRD;}ZJA0pR$(A=LLgkSp>#$R2kYAQ6CJ3qxmp+DN(6X4<%>s#KHAEPI=; zqd)+7WAoM|x2xY%W+ebLf3ybZsUs}|!!X|5bE2k_!pP}s| zgtfF!X@eei9Z=ws_GD$eanRzQ`=;UwO}yTj!PMw{`Ac z+SWT?QFxx?dRufy3V-){qG*1o8+Z0?q*a#U89L9E>(n1=oV02LFS2+hj03{0R=nEoT`{+uaWvXZDj>@^*nEEnef|xNmd8n zqU@Ytb#2LAxG<<{|JV9c@vM-Cu#D<&@=EfLD+9k>Mm8qYB(Q^l6spEMJ@mR~3K((LiJZP(UhBs!(-2$dF0 zcr#?`0yWYHKt2V#)8F7fA@#_j<~DL%&Jj>nO!uQN%5@fk)}7aL`3^~F(q@G+buwhG zYiF1i`MX>|t43{Z~K54KiN35`#zbj+fFR z3XD%b6$mn|1$XZLhlr5oz+;KXO#|mv@B#;rg-Sov*yJva}vW z#g+bZ6iW!9J3&eB{BtbN0+vA4a=#kvI*Qwi%iqp}x6pZX+nkBLbiUQ{9k%23CPe9| zeOuHZv;HtrXbcp`XwAuEw!ezgg#?UnCVZ_BUmCTXF)wA4X%M!tDdo_X3x^cV_bFzL zQ(ZFjg@>-d2^U98o{#N>@37@)EJk2s;w-jk_t9#j9*An}GqmH%AfAX5#z_>I?&4lo zHOvg2s|0gHdx2@TR@=NX$wDAP{5$&%itxqr01fWME)LF=MV8zh0~}BNaZ}`^)J+zK z%B}=nVNrUrUT-7z z>@D|0+2*!~fObgutBCU<=P$c4+|9fDC?>vZgCw+R>2Il!R!Vp;&7dIo>-+xxA&qWkYAk;BqC0vlHmo#F)K(8FgvqaW{SF@R=|(vIq!V;1`p7W*y+{GDJ7c4eV_i!Zb-0XB`WK1;5)fJ-94 zpYR&r*88rQ#&9pbP!OBqW~uBM;D7V-T{xewVoCm^SuE=qA+WWmJ85;7E(gdkVaxUO z^_VU`c`diw*dTvHI+^jCtu2Xu_XcRpk8_(T_|wWbp>*ZK1_tHo1>{o4=4-Hn=Z;w3 z;YL!fA|6&M(+RlgdkqtZ$FlBByVL2SD-D6vvE=5D?j9lFe4R(f68)&Ls6{QAZ+!;! zc#d!3{1Szni;guqc3I;>HpcJy!XYyIfnL@29L4cr>be8DAZv}SD*GTB;2`9?3Te&` z165U9^!~!J69->q+%0x*>HTvzP@cU16v!l0+5ax4kub?&IiQW?{HiAWQX%vL>knH7 z(-^%agI8}KE}${!OGYaeK^6*Wnl-a&Nc!c#@h)Vs8q=}U*f}hB`^lt z&(ZUt!cef(t;q;M&>Y|61lNP{N>5999 zEhFCw+kp*@+4U@~KT$#4@rjPS(x0dxo%rq89KM_ZKq*<&mRg3eig-FYud#DTtHTxf ze$*s&YvsQLbj@y^qp9;SL3U}1-k+w`3KlK%SWZ17zG^LWKWdDzVNBUv=V@)!(0h@$ z%WHJ_s^>QFkK)=<=~WrSoR#LI&bqOLCz=*7`G9>p;ZNUpq3_vsg1mc_3lIk!X5GgI zhm(9z0yIrgP3`&xSyhS`$E)oyjJ)=@M%%*UYGAoxDb+scPq*gurQju_2O;EX_W?{u zkmcXqon>pcfi7dR)e5O}q+NO!tY#+k zPSi(BtZK?Q><-TR$U)*kN%h>fa=zO$z#B7 z&A0(*U`!QlQ0O!u1oZ8+RzIyUDJirsWUW5i=>uF5ym-hRd6$0}^&Qx&eaTPqe8*~> zhTfufjq=iaJv`zY>%Q>gikQnN-{I(oMv7Q*zpSLuaHC%`-R$3{-x+qCCQh~e2~|8( z+r@-fb%+?{flo(hK4+eFLCW21A$l{#w_G^YYl|9q8bEZBlN6($t|DaBY-}Ak_9i9+ zO4c;b%9Op!?8nJrp?#9j*mXu5zKSZz_T^n52@yC?i4|V_DX_)Y8l&m`_qNP;`4H<< ztblPm8p*xTWWz&%NI7k7q@a9PInkAhvgAS#R< zDk;>5gG~y0y~2@$rU{dV$8z}qlPAZ**W^KRq*H#L^|yQ@yt_`!dw*)G^JDQ?tNx%XArC^5idHNb5S3DEQ z@a=0K|6d>M5gR45(KK;XBoUQ3gb3`oGWSCz=2?#Hr80Da+h<{f(d-ibgo96gVPk}En5{hlsx2W)-HbJhT=WgHsD373|}2BB2c zz`>zScF5&x?kY{$zzxn4X2k|T!xm2m(3&A)YB}-LI52#Q+JrxV_$kld-%n2&FIuT; z`D}eKy}w0NnXR*mRUXhLtWtdd)h94XR=XMxwHv>A=)G>tA|iHh8@clVNqZ#c`fv{5K#0G29^#>T())*6rl9K<59++idAwY<9L&a6RJt2 zo5dEZ(*8I;)j<~LxMcG*E2-;!z@~927BAF1#z+ys@Y<5?=1h%Y2ZG_oX&G0! zJl{1=vEs&YNzX&#K>`9wiiE)Ff|)Kw#70e(m}(2S8!BoPY2ZPl1&}sAqhgd8pO9MH z=#X6CHo!zqKfpF3b@Jt-@c|9DYT{VU#&Vq$H89gL&kMx9-pa8-(hOP7nwIY9C#JC% zGjb`R6^9Agp|4858|DT?^EzvgW_ao5v?M&TB}D)8!Sem_IYIbB|8~5dHeQ<`CZ*vd`Ese}+BeL=#03g+W96&|vTdNBz;Tox zR+&IceCX*uC=TCyli+c!e(vXcPsB5K_Czsm*a{?k`ze$!XH9fT5H#KtN5%7K*x!I? z;_fT6wwC9xmTrn%Y3u`9W3*5JcCss1HehhF_0yc^3x_TLa1J@pZWSjYNNb>9f*ZEC z1clHzOTm>#OKd5wGh>bcnVwkF;0wSf*<9k@;5-x!uy6lWaYtpGP0U0p@-9u&+YlNw zXs>_wL;ucmM=ldDpqVW&bk^^z-a9?OOr(mN`};LI-Hw7TQV{q5j-TOc<5V$yZDYw| zKA-dqad2q`v8y{9b^^LI5R$(n}L{!stIw{lm2~77TL_vi0nAyu@}fj zlgdIxx~2QJW!tiROvh1VzZMSwD31*kSc|`Z&kD>y*;T9kcSw{ad^hK z9YI7m%BTY}{e0C{tC;tE(_5ZIaRQ!Bu#a5eYKtAa&W4*=mtFFmIb!Gu_ALH$>UB(H_HenT{n-HB`y!I4WjLT#Tv1zYpW@=drkG<~dIN&JN z?`JIM5p-Y0qCkx5a47LxK7gD2vEb>VIv-Y@{2Zhq6cBS#o*1BpV_r{EGxHOAdz!D@oi-%-_lXTa>rGJX_BnIv*YlqM#8i4OVfPv&K~`-3_q&QDH;`LcyV3Pf zi!W$EOyLCIGU;|BhfP3NLr_As<3H zK;|_T6=G&mH`u{}+t|yja^2*?(+WT|iAX^4!XoA-O^fGBj$VBB zVY0lo-j3H|Ne8$1mR}de*kf3hrID6(TMT-9BTT_Zw#n@)C*1oD&q!SBxWxk8{}!rQ zG#pix$E3p=4}E_696l7y+gB$k!jakO959ik_s04G4FVufrLXY=#=1_n|(+{RTe2+QQz96pj9*?Gb0bdB}#CSs`e9?^KE#F`PSc>cGSV6$GY=r z0c~8!EcQzzX}wfcVq<*O&{p<2t@@T2)DU5i-S2D>^qlA4z^Apg1eLe&*2?(O&197= zN#q)XI)n4O_`}~f_Ac74h;j#wo=I}j=xctvgRTVnA2bJ{*xk{3n)Umgem=!@UUw4+ z(mG4?0VR{L+-_8HH>KWV5ZU42+MkN40ui(9{9l*=xPVf#22|j7k6v}AdBmQ#uw+83 zN_xW3hN{IIT|2)+n)*aC71OSZPncA$tTw(uMZ|8B!~y;Me}+}e9e}RL3U3vSx41{t zan5eh4qrx6iCmc*sgw?V$k=79XJ?CW>7#T4Rqos*R4O0e_w&hfgVf%i|7~2U43ru; z<(e@2(>(g18%DU|9yGnkg8aDJ-`wtY-VNkvo$&U`6h^Boj@5aHI6aSJM^rO@{7(;Ij9JH%*WeHxpS8&d( zhv7$sN@W2J2Amu_13e%)9Cq)AjZJ)SisJRW72s8K#sA;~<7o*Rs{P2~k(juWSviib_#7WI6rGs< zhGW=u=qpow)1*5TB;!&bz5@ItldEi}<37+90^19RDwIBT)-pf#?L2JeEJ4j5JkYGOPR}XoxlrsSSIjpEUnZ5z5*R^9K2hX;>|IT?U<-I*S{q5Sj zok`LnVb(XY&U>b}|0u`YriU?Mn~uz}c2e5KwKf%J6KMn@3Hdh20ubx^+NUoXOt9~I z{0lHCi)%mZcJW`0#r3a6W1-W=u@rA+Z{6AL{TGb3B@l38EGs6r9soZPXAxvmG zTTR{jaw8{z_NfP5jiu1(Ez-isg0t>kM+Z7LL$*epquUyk8w$^R@f#@H_TTyr7h zW1lnn6+@iT8JqHauF-feWjpU=Awp)nKMr*jX1Jw#>2FQ7lgB6Q)Xfp|(s^sfNPcgY z!)~gXjvK2Z^|r+lO=cn^TyYY2kGZ&CQ|%Iz95!#D5FuYA*b5?a~_kRDH80eh=cFu$NDW@&jd zVqRHSR|#iTT-6;lct^^De^s?=^7*zBupn=2^Zkrk!XcT~FmK89YC}p}DNxsP)t{#2 zp<|l;V&LNRK(S9Ci~p4~|B;LKQjlL0$8T}I{d+Zk#F_!sUYSqqE$gft7$Hc3mp)hQ zNJ)b?$c};Y4uE?ig9>Mbz6Wt=#%%R&ROx){!4ki_1w_PF376;?l+erhZs-7&yjsEj zv9rE41Sx70h z<CymtNGK`aC!QN@mkYMR)ys+Y8%3d59E z>E;OmeKmWlsqS6fwLojl{zwfVBBtbl_HC_8MjMZ=8q)mJ0{~ACo<00y?Q_;d+OJ2>f-ANEqH%Qss#n@`b)&0hhlURjEz^>J1fj9#DyyW{ut^j;4(uj8Tj>ke-fj_b7zyXGW*i<$^q+}r-Pd04) zuI{cpo}VSE&iLHo@ldF)?6Ts1a`()o6Q3=(P~bpqw#+>5K?R;Rrz`fIOMwlQy35f8B(zhe3i_rIL~nwVxB7WLzoUyB9xe)B+GsYV5g1M&OCT2nS# z$oi!+P>kutX)DO}CSBy?^&z=KwXk*Wv(JWO-24|;ulVn*#)4j0Who%`{LVEbun0%4 zC*xNRxQ$TSQ_lpvUssv5IqN6BU=;x}`SE*UyV{4^%ioiJC5+P4|E%M};g-*uwpcz* zT$lDxJXMq-4&AIP%iF;)^+aoo$Zb*|0cn$k3#E#QsP}+iKx3~$oF@b!; zjyMw^dRLHtLb@YE%dGHYqG4M^(5L+;L9|*r#zT*YrwYqPXa?O6H&(`n*c@LnSK!wM z*syvvta8Q<5%+XA=>A=zR^T`lK$}eZ3ed&fCHCp=}Gu zz%SdxitpE>CWG=qf;?&iEgx7d(!71MNa>Q~v#&}&%h36T)3L^gNqt9#nVzAH~i z_dC@wfeIx-a5U?YtxfN5RWp>-7df5gm6K??)xv9Tf)M7z>XOZ%TtBxYACd@BjlBMQ zrrvMZibZcs%kR6@so_DP1lYh(=A27FnNM-G(!m5xuO7ma6oofxa>;p%7hWioJ&Xh< ze!BCA{(4g(ofw$keAeEpr_5ZMp;C@Q~7o!Jft!yj#4ml~MIOsP*JUk1*a3 zwoi1FONkoVzrHG@xpaUF*YCjgSEW~bZ7rTQZsa=TOM&lVmo5C%M%)_C+Pe=%Zj?ka z&T1so3%*G9pGX8u0&X3tPU={0BP(rt_6@1lK@L#%p)!XDgU#fg^4H#~#|n0#wGWsY z#4A)v`f&}94M@$4ZWG)!0aM$1@jhQeNN?rj%>*nhF}n=9X{`W<52n4xX-U5ZI~-?0 z(X0%)xXrYK#D0TAcLXnCLOH0f5!MXEOd;5t?*6La=TNX{3ds}0?ed;lvMTg4a=3iK zEuy`i_nIeuz@gSqg8Cm{g7VPH`b!unD9VlBI+RoI?)PTghmmV zN_Y0HgP|jOsyQR5yp(%eK-I4g(NvqO-V)@c)jOJ{R5Zl$J)(0-T3WDOY8#<)~x_7pb?@y6tB$SDbLNe#v6%@iM6zf-h2G$>c$YL7ekg%Ug?n)+P zEf<0WJu9z6t*Gk(B74p-YjkDj=fLRrSX=Kj>1iHoTBXn+nVYaf-#x-Fcq?dTD=;k{ z|6ExZ>dxP#)x-Q|B~YoMKm@iV_-|zz=Pnzio>~@Go|W7(mXsM^p_aPuP-%7F#Y;*6 zTJi0Ex|dXd_4~0&r;HI;(pUAxxCz%?soF(}zN_^?Q&9->t2G4)GCm{Pwx1CGJ_0); zk^}cF`|aBBTbNj`dUEH{OV$Gi12^n=h6Sqr-K>kW#+7N7df-y7=u;?KPGr9wG0-=0 zJt7@xy0GDiKPW@;hVG7^|5D#2VxevWT%C@@%NC5Ru^IBMY&^H{^^}TwW8ba!I*^H6 z%nd)LU9kg%%}4F`JYRbUZYvwqW!ulQiyXp+Z?g!M0n^g6G0@5Z`+GMt<#<>+i2d%D z8&OxNyb6(U*Mpc^)LuguMHT9Ss)r+!WhS+BLPYaE^LB0lD%1ZCAWijX{K3(VZ)LUV z&e^y~H&~A7+lOKa=a)}{#^>U<&sFS5|}WkC4%^_Ve6GqGHPsr@OyZ)l1voo7;lN#)n+~FUN|!KjIhP${eng zHG}LP`-T8EK_zS6U?Asb7m{c+uF=0e$$z$TU#$58UC$OgVx~6{$zcb3@lB5i zRkHjWRU%yJ_C7@wLp!bAw5{9KjLVNa)xB7D{bJ1lC?v%61>!Lx;_Qc9WHT3hHW$@o z3v}jZAjlG4Pqh_eFUH&XX?@ok*S=LC)U^4-?qKfqU1mvavo)5~A(o*iW?}(ju z?fBvr%bfdh>M3XhZpl^Dt(gO?)rn;&QMA{rXz7rnYHNQ+dDe-%>5PzWY}l-9Y+a$d z-)}hbamI|E=N?wyv6;3HZ+QHOQ*gBg)`zO^T$fOPnYOaH@kOH}u|bH5_{TYZKLj#< z|1%zefMUN)EOX$%e%@2L?WEi(@wJ$IBK|+lk&u~fd)n+p$IAPVhm%nV-6{3Y#I{wN zxMb9W8Qt{cbx^tH7j@juPwlrGxonNKnj7dzCXrp(1{)c?eAMcE>V3+_+hvC%ZEvsb zR9q%2+U>?pE^zI-kp!!h>i$kYPvd!~SQcTed&K-KE=hkl3|*}R>8(8CzDBfMH>RoK zVzTBJ9s}}ZkBVq2?f5E8n-*!E#VxSkh|{B0AlT#gpDmu1_Ry_cW&$UIJ1fC#8VT}| z*H`k*_qEN&dmg{jBTRTA3-w?c1bsq*r<$I*Y0Ce+KJd6aM+uOGHP{@N#KTI#bst6O zujAmV{mV!OpO{2QT#WHMtbhxdHiCs>O5*qDW_kekE?o`#`GOzaeT^{yC74^Y$V91cBZ_m2MA7gvaudTO;G~ zZ3Fmn#d^Xqn6W75>cc)@dFsxOXA&6% zl)y*M$+?1l8^D?Q$~~i{lT)9yNMR3=isWn5hH|UPQEaBD&?Ogr;DCaBHJ%30vB`oT z6jxyZQb=P8^K?;((UV^=oYn;yfQrnA8RBFC^721p_zi#x5Ffq9_Ft2tRhd3~OaHXQ z?^+t4sL*wq)=u`T+RHqrs#;gIHxIeIj#sTib1RTOm{=QKpGJWl-aaf%2d(bi){TW! zeyDdFKmU)K+IyIft|PObKNf~opEhK646m6EtQRFSA7V3d-~N{4i^~;~@gmjey4jS z)dNI4n-&|`(QZa5Y-7g=>BrFsNprh4kD#<*pDbxYr^+2}-VM_Kmc5gk>!v1fdGL(;nDzHWOJv!T@Y48# z_+DAwgmiM^-hTVl7PP&+Vdg`rw=!569vU5!-ESwI#K)CLeL;eT3uCIiQ*2+xm!H4| z%W$nFBm5Fo(Q*H+1u*kf85p%2vUDn8o>}oRNk`7IOj?fLl6-U+UV)upkC7hG& zq?I8hjK;q9O58OY_^skph8{U*7I#DqSg0<_SPayfibfvTENpDooXZBMRHIBgXm|}h zz|W919lHX9sBW~W;pk-`#0Dt38B6-x89{AF+nU{J6}o_`1A#ofys!&M-elPf%K0Au z9D}4kdw#|5vu_X~H=cfG$n-$Y>Sf5oHU@PH85lo2V|GyK4pZ2TT$|v{bi>tF>AiG9 z@*DeZ5;@4kck)WS1itil8{R`+8e+bRklrae1z)1e1_AK&4Vx)JWz8&2%Eulmce9@r z;}LTC$aN{FckLe*?PQB-RX@LkFAy&tUf3Ra)wc=RlC(@envZ%SQ9Km5qh`+p;6K%@ z=4$XM*D8x=p%Toc;SZb2E0mED%Oz<~rfNFiy__r(xyHw)WlCZ+LlZP<&9*ceM+5%v!bGYz8p%JEU%?{33`>=8!*PQ27Xa9>0zZ9{a6;8X1s3@7+Yuirnw=;qD{wB<9 z)k}NB?Y&wCxeV&l$p+gU^`DLti$?r|CntubWxR0@5TcF7Poq`%K-GatlPS{mq3LEA zVCsRr2hepZG2?20{ynz1)^4CH5A-WYt*PQ$bMi{2kNwb=iS4xf6pC$c~lBY*wE z;aHca$h1nwXx2RIRks<*kH2Za_8HSV)%#q1ye{@8zZR8Ay`$Q+YpIXTTzz*{h@>CL zRGW65=WI~*W&h+_Lk7Zp50>MI7bNLfnhsk4_H@!3A&S0Rl(hYH#pvd4$Jyl=|Ai3J z)FcP&QN}^-map;vRxsxyfA$X%0l=_v;TnxCgvT2RNxvBRq1$)~qqC+BzJbOPw?t~I zeAhy7i>%Rxp4CQzBUI;R3 z|0+r|tPWsyg)`LxWw=KgTho^zOox%IzqEB;SC~z&$Ng%85pzHB@%Q(VMjj0+q!S`y zxl&rPz~R*+9lZmvFJdYj-VwX?pTq1k6#>#V@<2_+Uqtjv^U4z?l*Ep#)RFsStXVE>OMBo6CudaqdIKj!#RB1D}iQ7v^Z@ zT#vj@5L7*?Nd9<#_3Eh7f&Dx{+yYGG3@wWNT@!=P02EqcD0j^8{d^aBZycC>KZ=O2s$yab0nH@V1agt9? z)iYsdfie6WN@x6Puto)n6!Pr_jdsbDcU)YnUF~C-IP=U6nWuGqD#T~K@>n%d6@l1M zgZQeUFgZ(ad|dJE{Q=eG0C4A=!j)8ukK-^)=IOnke=XhzW$J$f18sox?odZLC7vA^tzQ6Z-_8-gvGZ)7(d!Mn+ zwbwrP&Z)5y&?rh8$T!w5ROwvxTNf^Hz#JTql2dfEq)qP|D_`~*09Ktf=EWzDl&~G+ zUFD_X=p)=H;|eU`7Tumk#^&Znpc8}Ez+e;ZIp;6Xhx?&6+lVjj$!Jxdxg5(Cw;Zcs zb)ZC3ND-P;+Jyagj|d#qzMkt!<^HmrFQ43+Pg-<)a1HqTS!O`x{Bxb6Tl@;^dg*c< zn1_`oO4&uh_w1vydcq1|lLBw3)K$g5x?p8M~;=YfZR^UH)e=xP}zX$FrtehD4;@%D2_wD!o)pYF)$ zd}6;fo5U`zmwa>aFWij{L>(s9%Ew?Xmm zG{Yy(JbO<%H>&fNMm7A$=9(c*?9fM|%9Fj_D`yZMk~Od6HB&tqTl9Rcd`wneG>d8z z_y2Xg>)#%eUzdxeIg+!&HGfvF#8lsfBvgMW593r8kB+`~cJKAG+^wPEFE4!b*uI@v zkPB+}YB7>(!@PyKW*y!`16})NDt(e08!Ba+-M!=VL-!nbm3;3R{CpL9Ta#)cTi<{j zpw5n)bCNVur-WsiPd*wMc#gb1`h%ZS^DaGwYJ^QsD$ZrtB+WX^oyMqy#$$XdQ>`H( z_NTNY7Rc_>0bd#-csj#J9urvdia^9t;K;yAyPflvPZ2wM=`~v8pIj77dSP9X78<7; z`o(k8Z{jr)rp66GqXaDLjT%nub!|K;Jc-dmNB@nDBcE^#6X3L@?w<$ttd0l{do%QO z{bc(2=YU~_tr;o@YKRJZ)6i1~KHk7VcczWceHaJY_b zEFxqk+w zM+;?Jwre0P>a+rZfXfEg)NbviZT&DoZ$95S8p zYpBtZ@UfTl@SHlq^n=sOuOfhTa}scLee+BQu{lN{Lgs!@WzG-a@-c5+0EV%dhc8Bm z1|r;>4)0~J6~To zU)53zR-J1ymL4d|8~4Aol^wpW>#-)cZ@CT_HvBZS5G_-#3iqkl6UiSj(V5p&vbnaq zj9j7FjkKcV4Dwu+Jr2iXhKr?A9|4Fwp}U`STc)`vS* z_{VEiwVE845J}DYp>9V$mueWx%45;-|1O67)R*CIIMn@_5rL9+6vC~rV=(0e(Tb^| z?1pp4Bc_+Xp5+MNXPL5*vZ=X-7YatetzrdL6Yf&RP!MUGATtKhKh(@fsAcTR`~%lU zgM82BSo9iLV9l2&KaI?{&X4malS>;oE6B~@T)e`nd9O&;!23$0pU!%JgnA$?ewJ~i zZvZaj;bc~1sH6EaAweB%V!&XY>}_5Ay~avlF1fg=njOyoaH7gt$8n{Y%$nS8Ql7ss zV~;DSz{2qS^`!dASs?-TBDr800d>~N+9 z;*K#7Vm^ivBuu4VS|xAdsZ<40%aI+8kbl7@*;!V0D}(0bpws*MY8w;GsY1oq?c^=b7m#_c z>MEvH>F$u4LBfaVOuH?kByG8SZYwe2N&8dziF*}|IT}Rp=cVvF{M{z8 zoGltGK{H-`PTaeiZB`c*%2oQ{Qp1ZC!2WX2w+i;YV8|0-Wo@v_{o!g>zCj-Tbcfpp z0BxG^yb23J1?W<2KG**V4sW%Pu&py+TBH=^dr-bs_3!TEmGYIB(~wm1hZ1x?ZK2{~ zKcrnv#26?aO8j1w_Qt$O24^dPSfWwKf728cINs#<(5%;>0s%o>bi=5ab>d@^ii5e_ z*I-ByPeVDCM75mz4pWyhYhtiy{Vx3+;N%PWK5Vsp`osHq+*zlgNA5SM^R72jw9<65 z0xb_ZIwx{Go}CM?<~~pSy{>Aw*PNSnss$K-6}(U4ECD@quW|J!{})0}#wEeN;+gEZ zTRLdHCwvwU4Wxk?tO91R=J9%Lkt8*^1l=>oB`|R&ZK&442vjVRr~A$hhm?(Lv@_N- zTquCZ+Q@xZ)T}hAMq4zUUm{Y+yq$;3)VH2eb(xC}%&2{-WTR4W@`UWPzz|)T{ZGk2E;|8z{`< z{#EuMMi^NS);Ln}KK&M;t1~N@bw*V~7RXzLXKJFe=qE@-3JA|!&kEBUsUIr41|l3XhA0R1K8-!^;ynOi@E)ymd^hgremTd1TQCEKtiC_G+02{pH* z&1ChBOK;)yQr;aq5n#Pmh~H7P0Qr3VVEXc+hcym$#~j3qe@M+TI_5a04sX@EG|M{1 zk-;JwWZ}4Gs)&%&363Grj#TSeNo)BG(+^d8Ou+Jq@44c=ovG^Z6gkh}7^q|4OA@>7 zvE@4(yZ#d2*b@EhM4Q!z6EuKxppP#Dx@{ysxD4_X+Iym;23dKAw`S42MikR(q99X_ z0Aa%rb5(=3@fiXaEao>GwzEM2i17PM){yaUW&&Bh##3ivXXB4tJRD@=XwqtAxHL!q z%%ij|9R+$lr7Yb0QOuqOIw7XTDgfa6+2`!J!5Y=fJhT+C9FB_1xuZm#zp4CsM-G-X zOnat|&o|nsm;{q>cK)t)<-rN&3*e6f3NwJ@`p-08zrqXmYMPUcc2=LhLOnw|N1EP& z>g!~m$@=$2&$*QI_129$sb7tGk}FI^qq6@#Sl zfX41*eT>0}v^~nnew@ZJYbyqG-4qK}J6|Oxtn1#-+H@l}q8*phdJ5N?){`FCqE$z8 zT2SX|FJAM?3sQ@CKUnt_V| z7sro%>)n;J0d*s{x!3;OizG+4s}{6veyHWrmxGZ<;ZDC>>Ri?)n?ODn9pd9`lsa3o ztoQ!Uxrw*qoYz~SXg1_CC)*6JWM-6dm7}*J({gVeVTwh++kP62hR{w*o<$mUpR?Na zT=}ilKBjuxxx1eE!<#P*P0@AVC8nYbAwkH4dS)@Er+C4jWzFdeknk(LJf7d>I0y17 zzVh>s0!2xY&5`?UwSnh~2oQ%Uu(fFGCR0o!Y?4?m_Umctysk!pop6P?%6P*dhZ`-r zc!2c&w}TpK#V_TDLF?@vEuxL1HV<@kN-Vv44v+BL*iF1oN;2nM8T%P&=5*WhMR^22yS%|+K zy#AWd^^dLTON`He@YEB5t}aBRGb}4Z9y9V#y&GlKe@YmJeq%@J|5wQh1U^z>zwe8K zQew+|1bveXW~;hxek9n>Tndp+^b7XAHbt_@)6vw0peehUCB6r>nIIHd}BSxO@C&IIuq~^ zG^Og)>Rct($N(}O9zpqs)uh-!3}MQ)aEbn?MWGbnt}fOZsfh1084|Y26?-$KIYCNP z`BB1-zzhy+X|AJgB`+fagwD)0E4P1%kH6B@*!qV@Syg|YFj4n^BrE9`Q)g>R9_Fju zJ~~4bh)Hpob2`rrP5wzd#IS)=6OZk??Bwgr>sK5G>|brAIc4Xm3HPOLsz0x}@}?yl z=LtMMFwK0y2Y`ksZcRs>=+#91S}Q51XfE;t{e%zc|7PQscM{)RM_m6$_`p{7ET?*& zFZESD%7JnN0+dzV!P_<*$$q&`*bcSOqsytHz7DZ{7o!tSzFThUpAAdHmNFpM|r(u885B z70-w2#O<6lCvb}nH~xKRfHd6QS<}@r)c3Q~F@X$I=t8!*#%e9LX~%%ucw?LI*1%7Y z#VYCWd#Ek_Xm;MrILQH|`FdN@zV;^7+cQYq0k>=Z)?Ejdl0-7y>6)`g$N;RnqGykD za11r0S`_U5iv$aWE`ezdLomfktoYx&1N77QW|{L`Q9-cIK*Led@J}q@`$GdCFRYeY zsuSb~(&h{^>qT{GxqRv%$?SI(2nyK+N(2&$z!y8~L|3tzH6Tajs7uG*XJ$cebrcNq zG8N-?!)!Yx!-r<0G9|>1_@s%s1BO=><@w-n-GvXJj~<9z&DDAv#xwH@tB)?Z`xiw3b;< zxby!MRN0$e?Eh{k-hZ9}9lFOK{^qm2yx^+Yc zh2SWI)l_UI9SD?v2&?O30DIOq2P8k&8Q$aI z=;B)KNZyE|(rjP6z%97IhEUF)IavD*znKGL&DYCX&>081h5iB@*l~a$RRA@l*`{zO z;^g4i6I}FC=f75xb_*C5CrCug6ncK<1ZfYAElmP z)0-E~RXUf+X?1j>VpnkPL!U}$f;^KHXarq658XULkblZmKgGhgDQ6hPjcdKu^)UFY zftW^qk5GC7>Fh(O1M@v91QO@#VFTx+;*D=$yX_as4$!s@!7c^$WZz0<<{= zn;pY5@8mKIl1K|u)kqvxpnxrIw?o284LGB7O5w$&c6y4(R9bEYrQNU)ANRIb#7t?b zphXtr&MZ9;0;mso$50s-3Fy+RGjYDA8bt!4W`(mYmeQ9h^X@+1#_YngKD?-e0P)mH z&YL^3szPWOnkjp@qSNZqdCyG1`t71cMF05AJFMo4KnitTz-vGl_b8b4eT@}uG8Nb5 zG@mATa1GmnzHGjb<;l0039|r6_-Y{?I~f`*-Q9<;SXo>f`jGAaf}zNUi2uxCiq2$X zs8i>x$ieh7wl8Vfv|l{@%ZgH83Lew2N+4GIm8Mgtv1y+ zct6N@oiEznbw6}L zJGoO3?WJV16^!xN6%I{Jwf&AcKCE9{DkR$bsRn?*D-b01enr>UtzG~>4F~kIML7-K z6AN`YA6R_bd4Xu5J-{VBcyJk)e zt12q{Q3TfP_wBFDSC8rZJ~|R|AGkqk*&FCE^1ov$qTG`lNyek@+ru+->cikimGD^1(RNU!XBipa?kKRNWA+A4=o$v^GUdE>pf=D>_c;JLsE(02edr zu{dc&>((vwy@(%fb+LPgI>Z!qL7{*mfl4{Hh?aVWsdNgU!VF7_a=%erIjp;emnq>D zgd!}ir3I9BRzO?k80WsI3!s(8mwWcIWl54oHI2=Rcjg53<~@ydlJQ{HIT7l?kby#A z-=-&mLs_gcCL8-MdIfqIlzID8SG>msdNfX8$-fS}*VA(}wNKe@`Y@|yH~Oi8x43Vk z+Z?mEi++E!=y%kAv;h67YIFOT@w%vF7puc@-Syi!O|Jn_Pc(K~_k!^}Cvnf6YQuRp zY}9|0t&0HMO;mJAGnLo@ABgKJ64FtXaIF77?CA~WdWgfeNR99LY(@t<0I|vVZv(Tp zNYZONjm)(&VNL#s$Jk>PlJ(T4+~!E-^oJ~dK!{gPlX<*N^p3py+Eag*zQiRd$1IPu zXZ$g*cGpsz%;P^$Bjw+mY1=kDBIO!B>U^NC$Pa8=6?WTT4+Oy`9X&RIO=w{vX$5D+ ztNjQKiNm0LwV79(N8NHgDO71t!ejTzoiiNkRW%h3Xi;NBLw_1E~&ywY;`J0tH; zQ8Yp>xxZ;{7^Y~**h>3~m`4IvzDiuvUgnEr!WH(R9>^e<{oQQ=Z_{sq32naLr8WB- zN_~7Rjl7-G46z%Y8;2#gl1YzJ4EG6#SucMG^m)EZ_8_^J=o*%Sc4;zp#l>uedPD%#w(X&mR@71-RsB^=DRz zS)4NMp9E;gW$&X7OdP_qNP%Cp^1aW?fhdIo7eHEXrd0HobtKITa$Sb})ug1$EqnrO zJ;EtX6aobaV=XODlJI_?&ejCd69D~iLVX57TgzSDrj*ZfaJ!)3BT&EVm45AVS|wYe z?~E&3d{sJoB_LUiwv--3oMjc)GiY)qwAtEB|MI>}k$%=09Lq4^Ih!a z`gh;obL7+jRB7l4yP`oZe4xWz62fzS=_W{KXg6A4*bA$?dYeQ(=y)-pT?2a=+Vc@8 z765g$fVVgVP(0MjI*Pq4X|!}|wDp1%8EWevO*#n35v34g6WFWojI{FRGypWrx&T_g z8u4u=|C-Hh@U)dyf`Rmb&2rql`l>;#(|P14a9adlm;cPV~kQ*QQdw)kaI5Qh~s3zFhgBIBVk)EQ0pa)$)U-O?Lx z0sn?`(F%fSW+&qen5{}DJww@Z3BDh?jK(t7N>*jLn;*k>&PrrG6qZU%0keA6cwD!& zES6$1E+@b++~~~XlX;Zl)K$v^-Nt)+# zVnP6wm6k`(yy$iV6RHM4?10ImYzm3>bVt(*jEsuLwbX0e)502pA-~LuL-= z>nv$z9^?s%55wdjitUqB7fD7=|6$Mups_Ln(1 zULRN=>5k?eJZLz8Iax@v+)wX#fSjR^&X(1sXu$&h<;P%kxaEWmeC5%Y<-|2Br*$pt znSFjudq2}Q1oi>_gjEM)312I$UIN61W!Om2_~|~mpam^|osxXDa(Q_4Co{4*hM1Y} z5n;zo!~kV+XVBZqSk<;+?^XX{=Qlh&e0z_Nm)VH`o`Ulq;@nWQi~$Ze0M*^pCqFeS zAMGoU9UxnHS)QM*0%$8#Q+EHH^WNg0RHUUaR`~H{?_nw!koF$#3bB=-&z}nbCqXjO zn#$i347wh0(%JZ&2Q&fYjIB%4AjHGWs*3%zUsb`wt!ewhtudcU72|ng;<<`K5O+Jz zcZoT_P0KE91C$y@7bP2Ee^NX;Z#i5G57zGoPCvfrtp}N(14>x<(H9s0QDN)1KNO#6 zK7}Aa1_SAvF^>%@9FKu0=el{ksnH32_BSm*ijYaNH0Ma1{K$2d^7&ddV2G#-?b{^f z(xXE;%Iqlx0|30rIpNTWOV8H4UjW%u`~ernfO^DLZ|qs*D<`0O&ifMhPpT*uTU42v9>CvC0b|0uJUB!q6;|l-k=s3 zFYDlSieA0|R1%r*ft44>qdxZB*fi=OHqF3v-}{X{mcqVT08 zh6Y-~v}PmgX@q)ol;ZQoJD}Ljy)M@F zb9qkp8pb-yCxHoHS8ViJLy_*|vMe%A-hgcC%g6o$A3O7&-c3rGJzpKxC)wHKxWm2H zfmtylK0a*MJ3Z7R04!+0Ok7svfGlc4cWtj1`Ykzdy^h~^n~>A|NHvLJGb_0!9Zk_3 zXT{aR&|{r-LCm)p@1>QsWa|K@{BGy^Evfob2!?^uVJrJIUY;{%iE-xXP_uH36y|Q0 zZRXBlW}!Yg--h)U zdmE3cN2HE=u8K#92zy8ILm|G>dcr+mOWVpdvlxDiJ_M$7cj+GTDf_GfUz*0&qebWJoDrkRS~n`#&R zTylrq<>*+md$EKpLopWN16SKd*vW%o^(%&yr*%>Q<*2593+Tc|%9!)=$HwUV7%x=> zRxzQu;l0MuuZ9}LzI;uJ_Sn?MWptqBarn1FE#c-rEEY-?T_!n6ET&thLjSCjbb+z? zaV+#gZ|Lg-%upW)5&-pPL4B0jBLZ2v{tkiRt5J=K9{Lkse-FycO`)H5f za*eVKKr)*LsV`3*kQG8ogsB4RMpWZmKtaatLS!`Fr=2Obb0r?pKV3P!HEm`;tSHaO zWTaR`3o_Ex{V5aCJ5CLpNg2{zQoFMth>8MH@$C860cVXKaRHq}Rj%=zq2W}OBV7a_ zzLpV#w}%PJaH@^CbCPQq>6JV#z*=wj#qq#bPJaqqMxA1(dkONbc1^~l)fN97wtm4{ zqA6M{m8?=3@`jE^F!ZE=I2v5!(s+CgtJVcV)C)ude29qy8?viaMAMo$OW`J-Yp8;v zrX5Ek+e1esIm=&;d5*4DYLRN4-k^tnXHm5^=8%{xnGLlf&o}&DiK~9JcP@^beYg*( z7So@b9~*JiE2-`P@BQ90=N&$1C>ZUgExq0m)TM;<_xdkm`#oe7$7O(HP(UXz z&a9{J(FGM>t_5GR^bziKO%-|ANQBcQRy{XA_u5HIT5qHQF!aOFnP(})brQoDVoL}N zYwuWPN!ZAx_x|vV4suZtRaQskvOB-F-UQu%)O&jp+F`?ZZh^XZsD+lP-=-w}F1S2L z=r&en3G@+Ucbtd206E6%V(my{N@HYTOwvfy&A5&xx&F+r7_kD+yq)i9yIIx;>Vh)O zzt2CY7@4&hvpW9#y^=4^M1i9@UW;dailV?}9}O?AATGOJji-9uYK0O7Z=AMnI60Jey*|`a_g@PkQ7ExOrN}y%K1Lv@Y&18&MIt zY#)1YZ1bCViVzwGfeFeK>iKiE zt=u+=yOKW=4(yFxIATK*apF{}r4xc|39}dlf~>Xybw;C(+>BrCfz*RnLlwv{Wf_)zYxsx*trY9~f0QlfGf{4*=T%*$N$>B{F z4bvrV;NApa{Am#D-4v7QG=oR+cJox|^=%T#xL>b;AIV+!R17&+GRwk}|}$O&>I`p;;-!!3uJI%hAtS&#D%X6`~d3?@K*o^pDEYp}c1KBY}Q zxmsO4EG1JHO;L@$VIr&!Yyp|d44{X~*)T4`mt?2h{!?R;^W zgt&N?r~TI%vKdL*>6xrXM#}w*1Wh7F6loTbaK+YKvve{G; z5@SzVr>rPgTmk&wq{lz5K^{2i9v;0pdSR5JOxo9FWMXW6 zZ@0><>K`AfDtaH%8*0OU8Z5gnB<$EO zRP&9w%KN9Z)DY6v3Nz=u*g*9>JvJ$(?bH|j&N&}6JR?sNq5rG@Gu32gJYMNf5rl9B zpM7^GzT5hR>L-&ed?x{KXQfoAK%mn0^og;khDd^4Tk&%CN!3WqZ6NMPm~-S(}KgLeCqz0O=&(z9#r_oN)|lQ zmA|{!5Krps6pny{YMr0zz3=FFk)fnTJ_rj5u(s(d$WsIF)q zua4i9u>WkC_!zT6wAdcn=`a%6(5SD2MEYBhY1ohKtR0lJ_k9_4HFpkx@VevDGR54+ zPM8OlCtcXQA+{C~(`ohwt{>kK^D!UDCo5% zG2Wbd3L%e|x6?D^ zt`Ph8**;m(+P>uwU|uoypBuYwZ9~nsFwOP;(cx7x&|wN8w~pZWVk+#oj_ZMGo&hu4 ztIFtT6mQX8wZNEvS1USt>UC)2-E~3%I=Ug~;p>VVe-l*$X{p`0s{s(i zB!2@~SfZ#A=k>uPs8v*xd6`1`&0ZASfja zB^}Ejmt6GUkGC$i8ybs|r9qsCxQ{THDWV$Y9uThWpD5|H`hC3!df01*Jv|%ZlFJG3 z{N!SWmmpLTD<#KEgf%D$+@@SqpzSGrGC87VY4)I^Dy+;b%!0`2LOZ|f#Zs7{(o;!8 z*7q>+)@Q8tTOuoMjTT}GVF_BA4oJO?)4?!}^E@|V zCt2`4NJVg?FnA_Z4*boN|D*jlq0~k)@ecvY{ma0%&U72m-z@vuTXzOy*d91jbuq{` zX)FWxJeldQr+$nrGL52bH}_Db!gu#}Ays0V>Ww^?m&9~`atc4g^eo1z-0C&d64S8> z3qM?y@x4!Mb3@F=Ai0winEc`$dyGgwmU!a*<#F{u>*f)Um$^PI?)aVs*4vTJx;TO> zhuc_r3b~oD+D$NT6%U^8>*Ucm#UX?{Jpeo%U}sqh^JBt=EOqjsU*GZa{m0d=qi%5Uro`Oz z*XL<DgU#3Qd(4`6}`I8CG*B{8e zh;H}u%>AtkdJl8QlLaBhqkdyIAI3W94G(Zb5Pv<-V8-r_0+q^QB^M*;R9yK&K z1?qtFC)LTp%SCon&0Ed4o4p`-^q%I9h{QRYr$q-R$BTGw9zdNi#?aJE5Pa(QWb)Nv zW+D+ym+|q9#!30W_HU1vo~^`eH^UaqcdhvY&VI{jZwHlUvvgNo>HNCadT=^8_~l7- z+GIAR6sCKr1i96!P*SpJchEDrba3jp>FPr=(`qprvsHTpSi?}bQSM*M0Ai6-OyA~?a~}rxJ70yb`%c6iw5mZE@OS)P5XKL4*{e<-{lMHc2;(EX z8>Bw|*BrYbzbg~ysAyBqaO~al!9b|F9w=-05*44g_+wr5YP(px>`vxXU6AeiO#6-a z=_ljM^dBx>ZvBBxLqeb9=_3Cy2sTh!zx`K5_N#`e3DUCjk>5p_vr z?Erwt#8mdG85=nn{)rw#JcpbWsJDvw_%a!-DNdqb|t~&5>in>`l;6;LD5h}p5;(_ll82-cJTc=1{ z#Uqm<%*f~V7Ail!2EOy^*GwowS5o{%A)RkiTzJcC#DR=2YG06fG+~Bh(aQ?7-|=3q zUeRF~j3rTpEPs$ad((Ok?7RD&&o_RT-M60-(?c)oA8k4|og5C$-sY!BVQuN!eL{m= zCQl<%tyc!=^lm0joWS{_K5Ov{kA*JlfP) zQS1D%`K!0jG<{!~t|M~}6Mm~o1CgS+89)D`2u0gTxo4~eU4mmsBko&+S1a3)F{kZ$ zvb{BQKJPfT4iCFOfTCyqb06nK&W(M%%`S_5@3|qclwZL!I}~;bc&uWCqKS1$-GOfS zo>l!fyq)-ctxLXouWfbT?h*2dxDC5M%#7Q7(W~(FB!Noc@A%tVtAHhU3EieSS**FJLn>yGa_w%*tkc zGSExrwv&*g@=19Sa@X;<9lqm~Y|nHO`a32_r|s3=-ocp^!AE67N!9E|$nov9xKSQ( zGf!=}*A&gi=LS8pw;db~Y3A-Rz1S2@e;q`THXX5es|bH0szYMZ_vK{UU~^h0YXOVi z3}W4DQ9;n@>!B%rt4`l9-h`Oh0smRG5}@*xkcx;rm{*Z1VgjyNB1C(4LKH9yNb!V^hElli(3lK*z)^T{gpR*RD}z_Gp>uSCQ%9K$f?+ODvU< zUUH>-4z=J{!^wBQn+B%o_j=PunK*v72WYBt&N8!19e>iYX!?5XJ=df5!HIKz=!9PH z=DQE3fAQGlyKLyitv`w`&po^NkuwH)?C-A^NMP>dZ0%uTO(C=;)jK`$->}U6;}H`P zN~BlFoF=`0N#V$44}G89G}isI#M%~K1IO{iC(G#Io!U!e$0;(>@Uw(lLD$1d~&Tlphv))OcdniU^8JLa8HN!4PmFq`Ht^QY2 zGiwJ?v)}la=Y}OCce`7kj6EW-otS+R_vJuz&YeX*^KMAUE0D$fKQWcAJmK(7#k871 zo8ee%FUiyAbabc{U%$OP_K$Y^bH4XZ#Qz21-??5f+&K{q=T zmO;D(#XCUf)47v=J?gpV^+rQ@M&dtO0P~&23(nnSA#s8X!EF=mT5p%+c|j<3lg&Gc zA;b6+TRwVYPMJV1TVZ2n_Isj5Q`&zpM!FJSIh&>4HWXLH=kR5wSVh$7E-!5??RT4WPJv<)?$hk%ztKwuVXM-+G8WQZ1BmA(>qq^(~~~g?~XeT)r~FW zn>+doscSLExKexU%hvY-n!;XvyjdSy2IpwFX{oR8Gx{!|sCYSHC(ib&hJG%qf0)~{ zt7f43KtynQ^w?(gZdd7(3+ClV$vy7dAd#KRUpej}cu+Zx>Gw^e3^O&)$sB>esX{fa z@k$%X*JQ+Nu^n@AYAHq99D2?y`@1Z52vT!qI@rSVi`I>0ne#iu4p({d5mJz& zcqMQgSR%|h)2yK2LXAcE((b!D9H2-$dB4d%HjM4809M$Umi$hHQ*7gORv#W;13Zv` zr|aIPP_8c_VyfnuahkDFXtaa$hE%ewOqX-AWEAIrVy3_wpbj1g5iNEWM&erhgx>{R z`a>>M0SLHxAIzI?(&ZJ4U3>48-B5{0qTe26$NK-KNmAxE8F4rhml5ZY-+FnE%o3d* zF`!S6iSQ^iBVW(w&2GDasaYZSqrN>_K3>o)pKz%SbM~fLu35A=F+UeT6Pt!fJUu^! z4Z`@UA@}n)U~{@^eJVgCK0Hl!txFkn$Avvm;ypUd`242&Yv!hbq65&HO1Lw2L4$ER zXmcZeaGn>y(krS`)jW~!!dhIZc@LvSLmpo{Z|CtxX`hW${CVI%3W5u6Fq;yeO@F+t z`w}!_OW=$>*_~45AFkf1$M^gs8bPl}Xw>(tIrT!jH|m(ZlQT4Pi~8X*-opwEXsEHD z1jC4&IDmbyvrq4%|FRZ(!Z+G}G7}@06ma0e!$-sS^}+bL=CnbANfR`@oMtPr$xapPgkGdeP0xI1q?Tkvd zz&E7-P+5VvZ2$#!Jp?lM=FR|$N<2Stvfpy?f(meb&rqjr$!C0{E?IxX7kd7U;Zqh@ zcVs)h{_wo%66IY57F)J0@);NT0ipmr1bbEIAOAv;(vUkC$!7U zX4@rCZ7L=c%vRI_=uJ7E+UoT+be={U7Dqe$di)*Vd=I){1fxAq2FRP^iuMOJRbdW2 zAQ88ies~UlYu^g-@k3(k8}SP_xS9*UU!C@caKq!MIhdAeZMq}BdJE2PlRjAF@qL1S zVaehp;=&-b!fWCtvX#187FIxE!-~T^soi67S(b=Qu$^2^0$}8}TA&j;7Z)e~WKo~! z>|Wf;)}Hp?R85c`yFu4%Id-|>0d_rjPK5WU@e2{P|sV|wuGh3vXz4qW?Bghesq%uCb~5FryjH@lvA4b_cAwpNu< zojAD}{=Os<=5pU#x_AhnYL`uZo-4jklzVIb!_j!ebjM!?AJnz-q!gQcn<_xs( zT4c606rD>Pf|GkxwyA;e>QLEd9^Gfc-Gf5%MN9Eew7vFHZB3~Tr`=O=^d`M{X=bF? z@vb=0WJd65r`}Vu9mn84Gf}cPGn#6a$$u&+%PpUM0gysw!t`%xJ;X1sKO~+hCT{o% z3zru=`?A|^P6k7^z-5X6#d*Au+&H~O>2*79r94ulVJYH69c{VE+?s8;F~u;wmQ$1G zL%;4o{&_FsTYSw;@e_zUL$h~*E-K$z`OUQf#3m#fSYX1Z*0XIFbnWUK7L&f9R&#D} z^HzA3b>U=%{m7Vrqcn{ap~BPGsIue?kh|Em9qc_|eF?QCA)Oop8tyrE(f^arhi1cf z=Zs)zI@P{jA3wwXM?_I14hV1JTTjAq+2yY-gO08o$tWab;m37_`}%)LYGLj{>>c=M;T!Pt0cnBf>8YL)b;?oWRC8dS|` zG4`8^ojrLZ;*EDSG1?wkcvM(uZ8;P#fyZ|T^5_vG;OkF$2O>Dyd=L+OO@Cj~-$Cw= zSad`t{1|SSz1N7es2Fp)oF^}vpLz1woucIGaSHpLJo?ASTW)q= z*Mf;kCn6Twcb|}*5q+JYxdmEP?(F-uHA8v+)f&Cm0#v-UCtWWJ=TL5NK_ZJ8?|pkl z!rof|j`O!`%OPT#$aDZ4GHc_k`OoZOS+;R0>5)TrBV54B>2fp6*z|T^e~i$|*P}9{ z%1Wcvh4VE*g#as(UzK#N5dVbQ{{#Vm^sZv&#$9-J&7A>AZdNrFa~T3KYY%%j>+_1= zU4QPlcjeAJ>KoGigZ%$e9wCQ;4~bBz*5FS87)c`DSdBdjJUNr)icULw(#7%t!3KQl z=fVyT4kH7Y@c-cM2HHJv+Mnfq(Vl*=rnRNAcf*wN6US!MFPX=AtxgU_ZxtuJlyS=)y~t``q~{z=MbHuCa7*|r)8v>I56 zF!bjG;QO|6_gh(t72el>WGQ~@-kuh~mP1oDM~$6C$Z3?yXz^QeI*!fl4J8YXJ#92d zcZy1GEC7k1%=RVDqg#&5!0dPHug%yrZ8mLv<|8NVFK+-bRP|E|#D85MIW3+Fc;d|O$D_pso0)CM0>8M8$l`M03&2^U0t)fam`1yJS0)>?)D z-0Z1^h6lw$xsgG095%+G5xzaVXI@MEWP$WAA9is1g+H)N%Bj;s!jVwRgHz3O0g?U8 zL_OV}0Hs%p{=<^5f4t5aT{e|^N(dAAESF{|yVs#>c#jZi;d*P{g>X|v_;21g%Kp6Z zx&Bmg6VABB!B(<@l1M&XX=)VkLNxUH?L2)Gsb^?3UD)sG>zZPg7PGaHYTc*(Pr;XB zwH$hW6i)x7@(t=gb^7sbWMeZkkU8*?GsxE0UeB-VbmLHlk&^BX6{Nd69J-|$hVC9}Xn3E|&-eF#?{X~#hljQ9z2}^L_TKkC z*s&RVuO_+!IwcGM3I^UB#w`jOg5}~kQmz@YWsA!HgBy7zAHx#D`Vl8>q)w({i>~l$ zu7ZH8>H-Uq%=z{$mOQf^(;dJQBNy1=29y|&)>WKUzctBEbx_LhkHU`6 z^)u((Skdy5;V*?a(noQ%A>Ak`-Xub?B+`?7)8wPdyYpdFGyT$ zy?!CNHf=e(Vk%$=f@vITU3i|v)1`z;;p>+cFeWqQblU$Unj(3*>x}!RXJKDc4`4o+ zzY_DJHQ+lNphq~fJfe>oXx(LWKTW^jUumC|vcH3F2+_5~kGMFLUtMy9%emcCE;;Mh zSO=NhdODVc1gH%f%y3SXnG#zOcfAd%N`6w@uP^~zARcO76-bTX?fYIIb%V~k*m`R^ z>9cp`4^)57MNFxMaI*!zGZQ?T1mcCCu4_xUb^dQ1cgLg#cV&aEFzt(B!i9C##Xi_B zA`z*QVJIXLPth$fB^}@^GWqroM2yS0hx>zenY(sT~ts{hdZ^zKZi_5&8+uq3@P28H|vZq5vfomguJH=_; zL7dM`zCtZVb&lq@Z~^9ad_l@bB8(=M2OFvR4|L!=;bLj{=BcNWJ~C80FyOfjJhaK! zUxPrwq(=fC?xADmojwIv+oih^P~U=C@m8v^OUcd#ZC>!FRDmpu9!Z_$E*C&Zgb$fM z$5Gw6gF62sW~wV%HsR@Dc+&ZU!N&|_BK+ff$xfWzD_(o}r)AL6q2rkqE7zX?xCtN= z?d}n*(Z2dvdlAzo85JN=5>jqSJa*+iT?_^X-a%dj@yrThG@vp9;uX#@Gn==+%`NB* z##S);qAfP^AeZGgl;oRl<~;7YbdJm@nsz%NmH;ZM)y%{6irZ6=V&484@ntvbW&rf; z=`ANsbVp7D&h7aqcqqw5K|(fK~}68Te{RUKwtA~B)^ z#+OJfOU#E8zW2vpBAcoY=fgvmJ^kD>G1#eWqued;D4)|9IdFslc9Z8J6xm91#4lf% z$LI91LR@qRA2MJ2Y9!bIvcJQml~vve+syUDE2a3w67x}gou|diJ z?fa^_fgG*&XmYeU2Dh`l^Y?!=@izsyu>Zah=wttGA|QvLu}q@?mJ3LHp~H7a=`=cGHyy&HcUFc}AIL?og$)dh68RPrcB|L@o}f5}D%ggM#q z{mEaL+KjUesRA~D!mckIXUM9q`wIKn??Cjw82SLB-rwQO1n?4Joqb}8lG^I)L`oN& zEPc{zqxQhHG59IVW9q7w+riO&T_}u z+6OgT3mGw&f!PY_pdlP4>zVd5*#xxrkv*8(D1J;4#InO^L5Gdr!ZRalOZ_noX#BdD zr=;0hwH`6OS2sWI)22kx{I@~t?m|L!BNxD=$;=wNheLEXCeI<>sy2}s`uYip{4}GD zXWiXmX-l`VpFKR(kLT3*`8s-rawgVD;EF)LO6>)*f*25je}~0Z>L;Su{9Z&PxKbbH zjI-nw{NnX@dqy}MJa>i*s&VcID&Ma&-2Q`uv~S?4xq77jWr0m=!(iqeSi{FeJQY{l zgm4(`7X5fOy^18)P+dhKi?v@Cf!rk3u3h;Ln=L<_43;C_PM$f<;3vzd^Xs{ftE#UB zsNmXqFwV-_j&Li!;)gjuaN!e5VK=UM+^pU@T0mgua(a;>Qj^%M@w>H+3&-{pSz_RX zJJJLtbRI3!q{1=UD=ya~^#1M@>H?~#)(hc6`uF4wMA-Z|-J(j|!U$J;)c4x=#NNKU zgj>loPweY&acHbRPgou>z3S!*ylez>@c27i}$i~Y%`z=ee(l7iQHs%2&Zt^5jF_FDDcG3Dn zfSE>@8;Bp7*^ggL?_M@`Xx`RDFuEJgC+7`&OpOaglgDRNvSRZ}I-Q2pdsY@H|5a%? z+KONQTab9YOsCNN<`6DdjbaR-+qb1gzy=tg-F~kM<0|-?lfI?Ua?kzo@*_NwfJJJP zEqRobaQzfyzBgsR-(jP9wD9Np*KHDO<6SKG)y^veO69dS{VhWzsjrtJ*dyY80`W?x zS5!(p3!e1Hyypdni+j3VQGb{2HjxC4F1=&EZC$=yWWG=h$_@xs3=*DI_LA8q3&* zqz|l}X~SwS1|A)Y$Aj&Z5E zk~ASjq&^uxTYM}x5!2KyxujBy9l(jPEX@re_a%O$6tT%s0H6)+i&2*`r_zk+zr1^W zNcfHW$E~~T0nh+?dQJjyGJb_sV@ch5A|VlJCs+9Q!v6Ss&y;`pDXcd8(s%jOY-}$~ z)&&t@wZX5hA+hZR`u9zDsxy(*3?|?eHufVM53*^eKQ2FL ze$rdcV}3cx+(jV~1~9F%O2^koa4~^roYnBLj2?57EEH?J;((>K(HcYbfh&jmGgb?P zMfVug+4shT=C&%#cu_kOzqPZfOIzOYjM%nhce5c@j+ulIgnKj%K8p7HwlfpiVn@#N zeOks&!8{`rFD@&1EOKxA3h@@F~f=AnItKGH@#t zlBJX_6dmjD;)_5PbX&tj-b=Tfpj8d#etZg}6UFvLeCNWovA3P9sx^6Id{oeTv6UBx zf-nrj_n;H1(*2jljNEO}eWkKL^I9xx;IG4YXJ4C+-T|#HC41$s@X;dr{V6pZ9ykxa zz!jdJ^BrEEfFbktO<^I*DE}fafG2o&+$XE8zq{n@{0$^oHz>_9p{HGkf(ylesctdu zJs_jtv0F9Ek9d`YZ+w3KjoIzbT&P)l_xp~iNGvmJMAt0hR~BP0D+!|`c*%KFGiCay zI8xN6&Y)<&085a)pCRC`SUr=y@q8_mIkYZn8Zl-!L*xK0aFTL5O4WN)Sn3q!HRi%v zu?2oCoinYCi)%+zM2L<>R2!7w%~4&HXK_}te^tQ|aD847+4+gN5K z;NHvrbOxHfSOj>JE{{+VPD|5E7O4`{jLnmcFCYmH6iriH`?tM*YL!Lh`zSXg_Lpx! z_^{WWL3ZC+o#tX*`!f7$XPs`BZn0|Y6do}>Vn>+yEPcIqHuNo|?D76)Rpo2{lBcb` zwEK(c+73EkzwQbEw9F+0X#`C5SeOYg`uGQbe2FN=Q{sfRF&ursxG**DT)V7S!?s59wZL%okoP?O3-lr+HAH>)QJL;(Z}oRKZ9*@V4B^joS(*fK{X|QMjLQkE{1$V5=Lq zOt8K8>6e!Yo4&+$SK=C*Xf85X%h;4#CYNs!oaC&oceVGm+K#^iOc7rt6#}p@9g%+R z;+A+ozAj$}-f0$HKd%55vcG4vf-wkH*I_mu*RE;=Mo`&B!Y*8Gtop!&8(dMZpuP*x z|2A*D0LeSYrN(jY^Z%01xq=I#g<_vEyaaHuopj+8JR!3NR zxW!AcR+O3y=4{tE%85rT7k zMWi1^?zo2T$6aUP_9NZU)ilCueod}6Jxwi@G4bOhI@3|WM-CW(^`73YW$#~o)0GFz(V-pan<1bdKbZjxXheXT)t>|}&Avjt z!mFl$`lj{!!4M@)=yAU4eAJfmi1xYXSX4#XD&wP`B9pa8%emLYIp3Oz${1TmIH0o8 zH_!5e+PY0dOj7kFsdx$`-SOX1XhLvrpCM=loZRQ4)_X4BE-$`Qs67cin_jszQO!vt zZAqu%t6y3BpBDhljWZM28dRPgiA}%i-V`!4d77f=czt;Ujv=rMNuhX@LceNkg;w|D zm?s?1ACUrCO^}8ck2(YmKz7_@mPTb@UQV_LxBX+uNOYVYR!g^k6ey-K^K9cXua)1` z*N@4cX}8I*LyXZ9URd~Yn`lH&&o0e?9X>>u69YxM-i&Y8<;}l^i6i*P@Vk8X0S=4A z`Z9DU@8L2^ywMLQHzdI8FHKZKnTjHWc85Pk)z%O?eA@zr7+qO}bUYoS=w$Ur^A}CM z_RF|DMl2D_Ic519c@tT*m}LMAqe%5=HHV$QrtVP{CQ8jY^f_#lUeY$Ls+{pE;gweU z>I2=l?QP5-y}jE5gjKzyTA%pe1~_NcOy_Ce;`QM5{5o%=)z{}X!)jh`;>Wy}9;gqc6Mq=tebG^ME#^aUzoe_W@Pf$cmbkzz(z(SUw(MfGSN zh@$C@k02!)UMH0U$~7)5huevcMd9ogsk1!5E$Ad?8}|8X9R)nksb-VxR=0$9V8IfZ#R{ztCH#O-{`FSjXl?1 zK1v5ReBOQ|Q!3D84KSH(!0JO93wDyY?_deJ0^aQhVe5j@m}^_JGqgJwMANt)d{lZC z|9|pPX+MJbztt>f7Mw=OYoaV-c?Sd$3Wc33Gd-LQ0Qwa3b*gNyS4;Y-djbMQ3~URn zew@y>aWp!lF)gP2Y7N$~&!IE5Ue3ohkD*&wks8+4JB!4i7!(-DfjE1{Z?GEv5a2P- zvUp7-BuwlqF&&?!J=0mfdMxpSd3qclw1QKEDeb1^Zo$OzbP=ZGe!7&}L4LJo<< zTlB%_fg(G$@)2MB%wy$z6*)ht>RW47b9l)^TcnyvTiy%l%qMVO8E}Mx?nrwZO%Fhh zA#(!H3gu(STPaFmNba=0hzMU#IU3hGXF|dEcZV+Bpx%Wun@cvttOK-UBS6w#wvVwl zcb80n6^F!HRFrPb;D_WFR&Vs9NAAav4C9!UT235UQ1)-ZDZ@cj&IsU5v=@=JpOJ%* zyDehmAfi_lkI2VC30KFb%&oOQ4rQOw>t) zIr!Mq6ZIWiOo&+7z`2A>z>{$V}s7-Q)7* zukdqO{6{7A>-{;|B>*yi5d;b>4&n|`{k#6=f3OYM>6a{uYdm7zEPj$@_J6bZ|0Dmk!DY|D`45bnm#we z3|vp&X2(h?;1l#D6AxV&0_)Xcd7Ldr%8WesCUtZNPJ+OyHsz~$8`<2$@ejG_z(O~w z$*urG6n$Fp!*^eG<1(69#+6b0Pz0hp1h8%h_F*H2;a1zwC<)&#>nur@m7crPLVfrS!m6O7>Ae}$G1g|ecDbd; ze!@nCgJ$5}m2pqPpd_pN?>9}bz!BuU>Jpjc+`*FE@6Ru!r^g<_52ocytZ4cNBV*he zYh?HnC0)`P2Uff75N4Y-C9rbXa;^`#1m5BLaH{LUN*)9sr(J!pIy|2$Hq8d>Ztnio zJr(H$3qapg&nbBA&{Z}_$!eQ-N+v}e|Mc8QiYzCEWIQ)f?> zB;Q^#2yg2pxD;5|eCNmW(ICl0GqO{Q2(N~>jf8R-W0f6&oSiRaQtHzBqFpqr&i1yW zW1Vsj3U0=b?k_vyImK`7I4t&K&81$dizXReDNqfeJCJjh+aJjPygP2I3gAHi4?N6(^M(PV z8g?JnklY**DX-m*{#^0K0G#vD()sfG^<>n~R|}J~`tT(JMxHnWf#EDcF`y~S5{3MT z=55J80}h7(-U4F3Wk|r*{Y6nZO_XrVN${6gb^~DHSTmh^^5_YDeB|$s+~lHgDn#r- z5JtL(s;ls)IT&8^ozDelLC!C2tp3`xapUs;azq8gq-V(C1)k-0^2>!o-)y-s2;ZCV z%n6zA zwF8(?yaQ_89)V}qK=RNvkplV1esjbTMV&gxHe07RsKsq45_453e7Y9}U zUtUDf31NNm41hJHH(p^BLYITz3P#$D)~JYd+VxF~F~F*?iR8a9G&%lG=uuZ}eK|CX z1D#89-^y=h`Bz`(xyzVd;95lNGw1EBzs%46W6!D5A%qFh7CC>tH1BQa%dLEL-zQ5! zN%}Pt+<#mq>DX??+Iwb3^3B#@tkk~*XE!I2KSEDaT)cHR#y69B74PygZDiIIy^#m3V@%#&4>zcPLsG+geq5m zI8Ym{+2-pVv7CA6{yhR5i?M1`TJ{%I`f#U{_Qfj3xQ*2WdmVnRW#BR|85YWBraCr# zgo7|8Ax?Q+>49MUYILQwstr*CklWHlOBd^aawIeBl%6qAC78ABv2vphcsT_{js;mc zR~VVwMhzcMPYv5}EbhOkzWHm{(NqtoBlzH0Yo?QBvRdd-i(<|_5B0p1jQ&K%h*LeR4aQb?8JgI*Bv;U|5i`RvX{!duK zUNzcjT)x2lRn5vQw~)uo{L5y7$6_Mi26%z|aFd^3o_4BiS}iAk!p2#1d6U5G*)acD zmWAV1Cg=8b<_53Dccx#g2)@vndynldW3={4p;-z6s&>B@Zln6k+g6cx81_%l(bJyS z+Osk83B&k4cP;a=r}iO=2^%v#+AaM#Sie?0eS*+S)Yr6^IxK$JeyM{WrQL#frEE_% zX=9^kNp6d|jZw8goMfiHR(-3UAB2SM8mhpsH5VTD=FsOpai3)Dr#&iTniMDgm&^Fl zsgv>*#C%O9hWQFh-ie90;V(^>!^F~+KI`WTP#eu6IQbny2)g#RN9&oHF1gU7YY_i!>Jpp58c+U6IAU*o-vC zr)m4jCmSdPhFoq&48;8TFm}92uP6F#;P#GA~6GOy>#1<^Yg5euiLmHag;SB%`6wRnC7lLvyx&P*`kT+S%!GzFMlHp7{Om$szpk zmfz%Sno~kB#Ke$;(oWWueinJu2*c;wIx8ucKrLxDq$=#|Wf1@BRtu%d1F>eD{!_*_ z;%*t$A0*zZSgKARZw{5nipNx}M5J&r|ZGz3NrhDoBL=nb%ew(aC*g zX|+u}J&mdfz6KaG_=r<~5wdz$Xw|Pu4gEAF85Y)GIgZ5?)*R+-ViqF}&$eOx_V`** z$Oibe%B0Zkldj9R~4DSn+ko3)xYPH zjKc9sylF0;4Mo*&4OJnTa%YVv^;3(pq}EUNyM@OZ+Ps`7dFi)(FO7bAWea^#jxE1$ zVY5B$c<}e4VUHpx9jYzh{al`7umBgA8ZCJs16m#@h#TmAtmW3g5 z(q@DHwC#qEpZB*THu(j+jagoKMCxC)?9-&~K2RaMr<7~m1|muIkpuPFan`x^u|d3u z)+Zm^3G`6jsSIDOy;0@x?V!?H9Hen6L&zi;UT5;6mHv!v7jhQ7LKc}P{OdUURvj1n z0li<(me1mYRD}~}-<rskRNpy)BiXYwC5)p z+)|DE>B8gS6(B(&^Y+++^l(34fAJxE)U10=Z@lC?V-3*RPz_b zivF=fw94--;=!D4_OO?z5-eoDL@iGo2b;B@d$xFe4)JWingbWg?WXw0{+a1@)*m7Z zEGT;R&;|U(->t@$J))8_Yo{-#Csq@KwlZiuIPWK~A~obfmsKIsj_v;h&ztAUb<~m| z)q1~)#ntV@Ex4ZLNV}CiFx=7HS|##~kv^~XvTkx}i^k&oTj_Zb)5+|_@D)&Wj#j9U z&x3|WG~-DJQ%A4X_-Y2F5bXWjwZO`v@O|$;U!PjMV)y1y-{)sQxd}{(D2g@uBw+CT zRu(%)NAwp{4XuJ_VCKJgsrO<1f5L8)I!5zJR-at;Q%vB%XF6{i8kC-B`6m#!!r&y(yUmCVYOYm?uF^u zIN*8H!LsBZGE8Hecs+nn5>2l^9)H?%cL@1P^7;E>`J&Asw#Oxv-kQ=7%)2J^Zmy7T zi#phuJ&T)s0Fy^<7@-Qb^R;Hy7D&MTlqjf9p`BeXyh#5==3OA4i~N>_ zX!x{963a80YP+1{1RG&H^la3XKg~QmwsI!5kfKRBH<3p#_ZyMvm%z-3FHCo}Mb^*g z*<6(ALeAc+D)zR(E?z`3$t+59w9c(y+-qsRfXG~o)@c8oNg$fM%wPsn+#4Ben2&gH z!Gxpqj)+)`=*vM%LBNFy+}HbxLudG*3uDqBM?zOxg2%A6Xlp`KGzxwV27JV|Gbp^1 zpj~i9Cdu-$ttQ|JHFWcNZd?U2$|PDWbSiz?I$6J+z!#*r{07RS%uaV5mmKecin0qH zk|u;c44!CGRg^Jq)SHuj1hanXABbHicB5lmAzS@raT}Av5x?BJUq{drvO_hir`6Py zFzMG6M8xZyci6BT**ZQ9u0NnM?nYV%uhaiJ=6YNSvA~b+_qKtTc=qBFHe~DxzzGqLnZEcPzFwQv*ANn4Hk-$^+o-MV z28wq=XdT91=aE?LRu4NgrL@&=AkE&M4Uw@%0;vYfgF-a2;7d#L0czMLRuRkby;IVGFGes0^;!~m-mZ+@*l)uYI3>!BNWyr@`z zR{DyKr40{@+hyNzjdqV2D`nqF7Hr6=36i>KqvyDeB-u)$2K{Dx!1^KoXw@zM2<0~P zd$Ob4y&#pm9u#yXdBuW&O|IP?8uNC{eg3f&{2N$8#1%fT=mumbCzYQEUDC#)hVk45 z^^aEE-|X#=-yhh166<^|ZARHWr4VVOCPAT^x?Q8Wo&fdn7VI7W;CO3zwr&OH@bzOH zk~;7{x~?;Z(*YiIQvFy}b_KPMUYmrJ`jg2QCzw~@T{yzcoY@Sv8v=H}G79gs{SyyVun0MvZv2pG+CoPY=f<)C=GAcV!iR&4kfe zzobV*@v8c`+!QHh;0_*ZCR0q0PYCZgzXB)vhZ=uHX&s>)?D~QiOp7=@pahM8X4c*x zXR$|DWQky@n)NB_%5MMcXdjLtg87V$meaQE^vG~z6JpmUk&g*2k>91{29oA$A{pCY zNYaL3r85+N$|5*z+HeU%Mn=^aGJP9dHN14soF!}P>CB7}?0nWV*WjpGd~Qg4=q=F5 z`OC-2JiaB{D*XFTNYg=owJ&)ssp2~tQ>JrqO>vaN$nOirnnk8oZv8b;ha_z<=pt~h zOKN=534GV@2!IJsu)QkPGmrq_N-;nl<|YE_rucD?$fi$3gMWhR>2GgDvanyJ9^kGgy%P05+2A1 zR4$jr*gx3JS8wEX4Q0gtRRDSTsV>?SZ?Esl3Q4neB0mzaNk=rKCagNujF$W-&D{Hr zR>cKV3ve7yxhj%AndN0UyU!rzdId9@4Ba#GoK1nt+Q3iVi;#H>g5_BT(rMX660Qh3 zu$GhSAN|Y~Rxk?Wr%R_7OYM~Gw<=Oj&zsA9+Ub^;Du}5!>jOX?G30GAbR})b@UnKDpuFz zn?pC@mh9wc3FH@>QnF&5FQiinYL9nS>;BMJkDDuRCs-KM>Jl8OVm0)&SDsx5UZe%{ zjeu=Ljem^I75rTQ_eK_1-q+Uq)AdHG^rD);JI&S?VZFN+F+G^rwiMi3@xEoH#o=T{ zsBRNk%m3>cNHIrY8BNwX{o>#e;mqo20}J_4r=NlLeG zRs!j3&JZW@18ssoNfVc8kLBOs`~NEJ+r5DOA&hUKHI-W=7{!+uORrvOJ%>=J>g@Vw zzKxgrCQD1GBny^)i_$ufC;FkvW@L>Vu*pPNOSWzYrwn&SelnrsRzU6~jDH{l=-r}6 zoOKnroyzK6qg8aQuf-Mptj@N#SdMuCYa} zfPEPK+5yLn#o#JB6l?0Pftr0}6ye##g$`Rum)bd=~yvI~z@#)D1GREB(@E>)QAn7yZvAmE3AzCK}!x7FOGI%l#K`;)Sf52f%=Q z6qvS%C4~QPszE)&f<#dqX8;E6L(rOC{A`b6){Fq>y?}vA0gr@@c!170lz&L|n`i&_ zi--!`481l}(EOhlfTl(pT~Ixs?Mo%Gaj>D4Lw%sj!sua*#~9M^2}R5o-&EglwyYi* z!aOoxp#GUkD-7Sbmozw^tOt((y>r+i=!@0JjXOuB0K?i~cS`DhU%}zy)Ja0q+Rl`H z-0ENet8jfZt8lw*OvO+r0zFM$m||~L8cm?qXl*Eews~4lgna%tyyKTg-wKC@!(;vT z6LJhWe7a1ZNB6s5Yv*W5ufx7K4}I0=0I6-{DeiXQ^!SRSD7-nOx}Dt8WyR3oN(!> z1JB)h&t#x!b8a8OkM-X8V>~8)l>PmI{t;#cqDn@D-qu6s)%TqG+Ar$roDQUllUDzu z_c;)N-k4w<&xHbMWKk4nN?I2Px>dChftNueb0xDJ@I1*E}r|I_k?i ze7uuYZCrsqMbi2F@F8gQ#iK?KtJUqpXm@{3S;T7O_XLiGeuu8JO#EAVdbBY%ke*SX zO?Y!_>5bm0_f~KXBHtnRue;;YVL!VZjmh53*TS=YW0fy-C~qYdEfd>R<>_a8j}TB|DNWPEV$8x=LXK)~6rqJx=suI%pb)cc0n~cPCC{KQE|T z>S+E8Oa=dJIVB5El#B#qQ0ZqR;EPiU=Vwm*uGPmUa1L5k=g?Jx!_wk#@F2gGOI^VB z(2pD-sDRP0JiT$00|W>ni~xFA7y%^z`4NhD3R9Apv~C^G=7@p#s8Ggi3-V@YtlxA9e>X1xXu%F%tW92~UJTciEG6kqeCGRs1)g=$ zZp6GaO=af1^!#dO;3e?x=BeEl62(qp(a!dE8otu3$gkSiV(~sxpVop|8`GoWnM4?w z(K6jjAWAswwMcq;zG=InF1JpoD`oV0aQ5NYf4(kh6Wq}4&>p3^DDuM*l;{ZF!KE4C z<8knxc4!qI(za%i*1}S?FetV8F^(s`cYLXt)1$kJhRDGB&FQRYl0&Z^_<1Ls3PjJP zh799|=k#$20;b1pBb00C+L;p%eop(lid(xZAU(bgNAC`7g9O6dh$k~2yUz4vzor~s z%&C5XTzl(kn^7yOO22qg8OL7BX;je%NPuyx+X+=I3_mYL`qr>0?sj_)LPvU^tWVO* z%PyGDdGRWe`B~@h!|{@GPt>P`3iI;6Sq5M!QoZ~|C3L$H=2wok1KUdpaN}AhHjj?c z>Lqpi-?7ykKe?3D4efW$^>_OEvT=t0bYN%@P}th5fWqB3ga*SXuh0atN4daggkQj*(9M0mQxGnf?4obM08(BH*kWuJWkEb+NO`V{=RCpH7FExe) z6?u~FsIu=LnSd3dNtI)hBkaIY^|D1Y90^{=Iap6JS8|3G@$;#_N@Ex|vYogPUKv{4 z9`l`Xx1Oz3!6U>H0v;jrt4n;5Apmr-*Z3>Q&)xeatX7|^*=s@hj-NdY#7(jN;o+aT1X$aPfw6enmu!ujk-L$M(Wb3;yxFz}Hrl)hL}-`nlgXRKpXSj*&UWXr-W*bV zi#--;5CNzqV+o;^2^!U}sjx(x6|>TlXW6Uhdqs2mi|Nm-z}Mb=%iSeuAGIpaJZSJQ zE&pbz?0hX602CxmJdAimIn!2C@!gJ!_j1(Lvf1cQ9Bw_7mK2$lPpiQ@Xd5p+4-pFN z|4wsZavvR0JyDa;m5FX@$-ya&{(D6y7h&_Q^iLKe1R?y8Rd{IuE%B{L2&2RVpqsXLO>&5>;bGUp@1E23gw$$1t$2 zCe9|B`Lv3*HGTtcE%E*#6+@$n0d96P9_X}yo#@Q@kVV|D)ZQ<94f(e2`u~A1lGa>p zRE=+2;z-s&?fo^o_ALARXS+tCCEG4t7@5{ePL@!DP$-A-+%Wq7OE}u%vl2;Z3~|xJ z21-}*$;|)_ryi`RPW#&BPzpiMs^m)Mpn$(frP{cjd5Z#O@Zt6{$Li-V;~hxs)f&x2 zDD|%eF>svk{4ihgRlpQy8lhB8)KA|S!L;72v00lDrfv)CKjodZYD!INfMZ<3Bu0eb zE<|<)Qc~t%#Ve{c4(Y?Y-Ad-nzxZQp&ZU&??)f;Q2E-01YPx>AU*X^W#vKS$IC{`C zu$l_q2yf9xyW{+8L4>XJnYp@6Np2`-wRE7#_=SqbLEfUVaMs+8ShuC!BlD3lL^lGO za2%rH);1i2v9)fEv&%VG0hj?kFg^cP78x@^^YSb;RWo=uxJf10nD9!Ayu_`QPy-W& zSBYCN0!41T5OUQ{TCY5d+Z3p2?+tkFUvRH;yYcje=3^Xyf|oOAQzv{KpjLweBS9Yq z+N1`r3{G8>tNcFbBiwBqqS+6gAD{(dsXb~`rCr{iyv8%UdGH1&5-Up(q0N6##Lixe z>dI|pcaKE+I!&O>%YJi3jaroxbi9cu`oce#KFNNQeUpTBVIpor6=!<&U1Thf)IQ3X zb#dcmS*=|vTkF0#oQ^4v`koFU{!F}nH~92k%Zz4SV+-vgk)j#W`h0H~upqr!ZtV^G zoR|)zpBufxRJRZ<;p9W5Fs*g?a1CK)cDI1lm%DG@m`tSL&%KH?Z7l1X+nD2FJND=-_d^Rn%=~HY;%^huv)pF3Gn$(4tJTSj;Zm5q&`ON#%GfNbw zW!FVNCg!1alli|yQ;c1$8;CL&E0R8o*&LF$RFMve-N8XLeB^nKFD~PVZy#|3VL=>{ z6ZLmRFkeINkvhMs5XDPoEZ#GBP84Fq8Lqxt@#OTH7~UH`(C#ey(hjsjn8q~W^;w7n zaG2~~3Y!@0koen50uPqq*~zO9lOZT}uq^h>uWTaY)l98)D9iq0hb`dq;%ErnozVM5 z4D+E^8=Z&UCkN@>d?`sWh;`qE?B6#D*k zo1aGWHzHoM$2(BKdN^Nw40O%pxwk`ikWE!> zmIc>z;(YW=`r?wa)#obz0DuYKRa;93&Q4@=tdFT#Lx|y(q24{?OJJqFCw7mHpFB{y zP-|9>E-Im91N1maHD>Bl@6q9G-kOx*1Hu)+RtWU|17x;9@wXyIKZ5aFKVLk6-*E^G z6P$m=pR*u68?7^nQho;<8}u~GFpfK<_gSXoQ9T4o2cfteuX?_m9Tk6uP$1SFR$%2j zWYuR6ZCoqGxdck)8$|5Td5$HLJH~xu*`sOpGz`3kKkqa@=!-pv7F?1KN1n_-3X?`Q z$B&{toa6cVSvY|o?w;B`BNoL~7uav`vc0m~ zVNVisvV1AK@l$uKIgTf4Wc4IFPv%HArksp;E@5Vwh{Yo>*_5)7iBlae_UQoF3^dqR z>k%0r!J;JIcKsp0F-`CjxQJ-8l;k+an^0B(k{{83uF`;iEyFJMBKHx+MR z<|VoUv?4v)89%+df37ud!rZ%W+kW?6B^i8FauA8O%OJpByyY&!=H%Tw)ZqHM2`1`7 z`#YOBleMoy3Ppjx=(FQK013(8>3+C%dDIj(!Fx%c+|9r5{|Bfq2`SZs1uDlcxRj;# zVU@reV#aIk=Vr3zw|y#amt=2VBE!>Iqz=)o?}x@c;|m)ht=Z&@s<%>v_F$_IlQ55W z7riR$w=S`ePe9?VbBf%v1W^$D&mP9{oN|$}wUTe|{f$LJOAZnQ_b*$1%K)sA>CuA) zsy@T2=;A%Cf$3Cme=IHnJ*pTKi&NXs>}>I#voo>~10M(%^U&osd(=vOz~-PO16`yz zEB+TS5+P6iHvIujNeUtj)kWLgxoO(12I9|eieH*xyvIq=`od~lOI%BanjGK77OAAA zC8WFWg+tLdHSxPN#&swGi?;1@!N(KI*M~A@?!z=t=2@{4xN%r|cAD58@7^goCY)-) ze?>tWsb+h$5vk8&s;lh=_N5UuTkRDZK-O|+X5=5;I+OAW0=dV1>ynbHj!F#{zY)yN zh*#%a#z9*ND$2bYewuJwcehQc2TGsGF73n!Ty|_0j=LZ=HRzT(J(1=DN!tUET6;@FLoDQ4h&=9m^0sFcrh(y|C*;TtEV{m(b5E848;15X(v7Cb|OqW z$MPjeH^KLMqDb}WM6fdHBnd@J?J{F&U@>@b@$3IC<)gh!Zob_gKFBV_7#L4D@+E-w z6~gaR8Nfqp-#aRJpDyUInG@9FH?g>k$|bEEiRtOE68RGSh^q#uEyIfyh_INy@Joya zGq6EI7lbeeo>w_P``d{EkHH@i(gxEnK1oBq)g1 zdO{p9YcPx10sskBW%K~dLMdW~9cf$3sbJoGC4DHGxJv(xEdyV~T*qATyx`K+ zqQO5$B*{H{J`ZxwOHqf=*R}pxfIwi^GzXAL+!lN0bAAq;gWAjEh`nnnnKz8W*}m@{ z1$1Phlv;?AJ0fUD!H0r3nIwlA7&z@Q6QD`sfEedaQw1($ZFzGIJappqTD3iWIs4#tYoqDd2h(fk<4BB4h*C1} z?+T*FzLbDj=&@mPukDR=X?EUvk8iFh)aFayYo&QJ?NdmP z00Za2#S}9TJEB^xfSK%fhQpPBxzss%50@jPI^1^X{-XJjN1x{ld*jy5Ge}c*5}!qq zcD2Pf$#ccG^cw$aDJdM_UV({TSbOWCEz*$l*YgBCgiRCV0C-TyOxSikNvCt-Ir3-I zGzQ``G82iN`n~=kF@wzqIH&pFOmeeVz25 z=l`-?89gvO&8NztB7ule!Tp&{`>=FZPISUFI%`m#sEEg8y|TW;vwb=o5TzHo8ZGZders{RZ z(;1Et1U&@R3$SGU<%ZC&~6(CqTsPS-TT?Ph?|F{#>G1uW|6e{AXX9qxG zCXX!=+o_)B?z34Q4TZ%i4eX!Z?cI6e{<)zI@Rev)Xyj$o+JTgcg|(_Jj6hkNz95rf zz9A7YeDv+)uphy)HTvhcDt6S_$f@eBz7@Xx-$@DOo8&g02I!1g;-qS@kign47wNVc z7>+?iDY{khI|_!-Z){OuR^1yL{A6nAcpTs--d*xTuG=TWk0fDoyRQ(Kvmm#SHZ}m& zF^WUvV(T0K&LjHoCl~2TB!+kYSyBi5e3RK|B;WI$`tm!Y1UHT+@E2yC^*mvmf4taC zmHcoVrWZ}|;6%cm9?Y>KGGgK_Jjay^;_>wFJ66-RG~_nqzj~Too&7vw_XqQjRU12T zbf;9kUi5EA749AYAxz}>Lyr7|Huy|+dExO6A7BWC_(uKN8g|7yAXrlJOC-*D&g`AmA{d_RBsAJrHPxe- zQEspKLmdXyItWSvl!lhJwjmxuZpUM+zD*5=!%xBRi7%$oF2@-&m;Eg=SOUdBan+=N zDQ$P$Hgkz;X{M~TkhGY&B-^@5;vtkK5g-|;zv=q?OcRz0@|~xwz>9 z-B>~+_c6bMa90N(CC_iy`Z}+yZ@gD#8b>xFJHxWn6QxDR#$9WLh>Hn&wC{9?aU*}X zTX_)LCJCtBQc-Z>g7}d8=r^%h`84(A3W6Rg05K`*Ij0Q~-QvJpIE*V^5x>dWEU=K|eZ8F@bJVp7 zx-ih?AyJ?Hb@z{X1?}F@5~*DM|!wD)hlQTs9Z(a}QF9=ZS466NBB*6|w1 zgv6nebA5Cy>Q3E8-6pJtBaAYhPCK<4R?0rE%C=#cv|^o)kzbHjuk#Vjl0(a&7SP7C z&UocRz$T{0(U;G1`dhYVF7;fvfnD}Ke#ml(K%jj7K#mE-D*hQS0F%L%z|?Mbm;vsq zEQi3Hj}_;Me{uF2bqxqmo|Gm^el`uCKBl;)fAi1|ef&ix6_P^ZL{#_S?v>~|)!(RQ zpLWf!FZ=RwO`kg{IXwJJVRgAscToNm&%XAYVeQlmMKX5TkJ6bP(q9w(m~9pGeP)Dk z$yB}({$$vvwKJ)sdWEoHvtz4?p~R(G3N;%MC#MTK{59yhRx~xyPrK)P&g+egStUGl zJ1*EFw+3Pzq1V0mUy`b`P_Q8F%9Tfd#{S4x+y5w9yz7xTsnL6@I%&5$F}={R?I+k^ z@3L0JjzISbHULZZ*6ytykQJQoCI6v5?fy-K@pHH@rHr~#nfiAzzIUH=o4UeQ+`@G1 zol{hFoOh7VG82}R&))rFZ(FAlXEWGzF=tqN-iH+BPbu;IQ&Uf7WG04KUSRNKR+PkjhM~NO@CDZFB51CD5^)Hjz zB-S>$%}9~F(>5O&5g8qogxEk-pS=C`_m-lrXLcVbOF0PJ=(olUFkfe~N_=|EgSmRU z+S;cQBl)A86nZcBW`OhjqxrNMDb4Uy*y7;knM6d{MujW;fV}FD7^%ptX5w436_T}U z3Av$w#ZYdU!XcR>(KEPm`f@6iK4VpAiXb3-?{4@S;P=N7Qs14JYn;hXl73r#v(lK) zpee9D-qm{Je|j4f^&ySgI^tuK$B*GudqM-*2kHq&A=(2DyU)KDL~z2~@5riq_#2K` zzBX?bbKWk+3#06#Vng`1TaECF-gs!_Q@}+l9Ur@Of=nzob@<8}y^B)4mVyCkGrI2rH+ z-as+I3eWHIjZzPLbHiWZb)z5*Mxn!7-!ms9+s*HzD96z&Iif=%JGYn9w#eLEeW_900LR&jsNv)EWk>DTW^i+OU^zjR7gw5d&> zmS3e}^#;Wl+~^pn>=7@c;mRIAMRK>+I5hu&WzdkaMUX8*nyabUyP*z&_->3$qXZ5vCe?zWFDY_1_LLa7&*2U zb6|S24XOw`dU^Jy-1GE^mBHAvG-SLyTHT*jGy?h11ohJuSLpT1l#p77Z1?o%UxdEE zfs;-m)~qYh56Bhby z4;u0Wy`uW@wTRqOzwAZb;G}_oz4gEQsHN}dJ_;uhO)`6ujTksQFUtI7>|aMAK8tss zI;;)e;+#dWDKE0i0qSw8(Zo?AqS@eYUjvxfA-I#0>iRWVp>v#83g*`@ZDFN&;gp3` zUW^ovo1W9IVS%oWW&;w^F<9fQH-D{@^sX^AvvId8@T5L?T9~L9r)JJ=e~0~{MREw) zs~S!Auj1qc+t&<~Kz;V9s@6n|dib;?+Lx|~ zHZ|BM(9qoQ{DAKch`bOH$`xSXM78#aFs_r@TVC~q60f=*#{VUY5USjadMxwvU-U_w z^rveGIgbWCPfHBKtNArDbIhO9vcqV3E;L)Cy2J-l`JwJ(!gi8Zf{}yAv%^E;qS>lb6@h_*C7s+PBmP?YkC!7^LMFkP zMFzjcdbD9L`;ytpX6Hysb``|Is+6&l$gYUHIlGpOQeo9->NcM(fC+9BT=j7Oc0pM> z%paLiovuxjbODK{RW356Jsuz#OqrMU>OSq55i3s1DwEzQ#9Y}_25#gQKRn2)ey?$K zTN>5m36{0;(RY$22`_L_x}fWlXAXdWeaZGv@jsjc_-Oo#(%bm{us6UaPP+c}H+(_>QSrfcX0;z~ebv0Bn| zUu|H9FmSvT=KM3=eqp0g{GXH^TqNF5`ZnPxILfF>obdE;F&yB1;N8=Rqa}OQE^5`R zwo?2BHQJQZobwALXhof^ zeV>4x>Yr$cJ(-#mvYFAKE3!Z=t!0#pjR8!EeUw{xr8BcfRkDX2;{!bcPrd>y)8SPA z$cSVltlR z$+1@bQrzwzJ7^-WHMmC+8Q~WGwIuhDwl#pJuct7Qd9o}u6(i@p6la!=obRvzw#Y8f zEuE~BZJcew;yp7l>qS~L_JQoeR1_!^5dVZBO8AiIt49pdPS>>?s(d6D!G+A|{CVsl zIpzBdQ6m>N$Vp~m;1Z`DzRK2O2iZsv1!C#gx`8&yA_B@~N7yEe0 zDh9{wuTR(F^tcHX8owC$T{!>8!0Yhfs|Yp@Ic1a^l(e}FiFpi14OVPEK~(D59+Y#XC>_V#ybUU48Wt$ncw?hW*iCW^ji9P-RUl?g)6Gd`G?tgLbEKIfqoFboJMHZQbwGlWQ+FVE^Dh? z0KFMtWF`<3SCfNiB>Fc3kO{OLre8`^?>UmP zQks9mVpeET?k+fe<;oUuk0dNvHKktjmWs4%QwR{zZvXmf1*e4gtyK$6Gxst$y6rf8 zDmh7BCziR|9a;%~+Ea|x@qpLMaozcP)Y)6dlaPHPvdREzN-xU_83WLNjMoqE!9LF! z=`v;(6(A+Kgl~R(d24+dNaWzyxn2jFCSxRfbi55M0m++xTp!0@F@S47gs==B0%I~Z zI{E)nrg4zSWTUQTDz7IhJ=ApchB$`S=D%V_K|a0(EM@e)Pk+dCAHt&TG50-!FD>y{ z1+6-M*1`mRyu$D@+BB7>X(n(Mku8-b4H86&D3_YBf(c|cJVM7k0gJ9Pt@3RlzQE9E zLo*ZlTPi+zVo$7oblNVq5RsHDY$21+Je8&TKty-g%B=dzvq0vNw!j4q50nz-sWZ5& zS}OVdBOlwWH0{u00N~?EtL`xpBy-kWbUx^BYVDKF+G2j2)|2~)GNL@GaEv`*{GG)T zFhv25I=Opter(z%H(mw!LR-gtoKk`03*LqM3Y9!@#NVBdMo;hihDobySZz(!Nte+( zUEVAz&y-d)M?}w$v!Uja#^r}9AOXPn&y!WD7p9yi@*6*g$_t5~xV7vrRQjRS^gjZa zUxlrFkVxHhOaf69d+gu2`j9$$=lO@~X}rF4E#!(;m{?6ION+s6kICKs540-}tG#~5 zLJ%tx27$t-2Cv~$$tD*}PJwI?>f9EtS4i+m&vXq7k8pEt@R;nbe6sjcB;ak@> zx%=`W#I=mp;nxJo@b;f&!MM#jA}Jvm5-n^CG%gZVu|eZP*|frZwDU{BcXK9m5)nnq zv`36)yDa>c1hE3qMs^ol@VY3gnu3+H6Bk#m1d^88-yGg{f8If9H3yJJGZpEnKBo?v zJ9=ES7f<$TK&ZxhUz+(YW;UgN*s~4L5V)F~DFuq*kAEY>@7_~0+@jl9WaWue2`vqJ zs!^Y~iTBiwIEl@eLJ*_xDGW7wK~0r06PYtTCUwXK1fC)KagWG1CWi`DMasN%1`z3s zx|Mfte#E*Lnfw@6fttyH-{8!pKZ-g=PYC`)_`Zl#MA0r9S91(Y{(ZuP0%%VQyPKtZ zRP&glT-bV-C_&fewgsV(7+eaMR?d3kjCx+6tFNw-S2;^miaz1}gEP-LA16aa)T`@9 zPvz--nG%N;rD~Y3qN=QQdbHXD483e|VqUrVs+-N%UTG$4msg3RPTpvRn?s{yS(&#m z%%+kX##2{lY(&*3R6?@0Js!Ican9$T%oN(``?w^l$97(7DjVr&tAHb&wtL*04s&KO zmUP1CWv?PmkKA)Fd%AeOq4YKzf2cL@pXyVtvUuKu)mj;tG#|~#2=>BhjY3VTXjy-#AhJ-^uf2ktO=|LGp1m(=WK z0rkl_1q2ghAG}L{r75C${V{~5t!UPZttzFTS7TI`e?a-9SiCA-b(+4OC*NVY(1Z%2 zCs@Q4|GsshIRvbiI$mH@WV3ljwK5ESSb$QeM_bqB;qz@gmGY%G&>7AYiAtdh+x29X zf?o{mhcE)uI5X{5v_bM2Ej0`LJ?j6D`T%@@Dl9M2`Xhte^It&Ec_Wm&H%vED5i^e8 zRu3yUt~xsEP^kpYqTE^}9G!JU`p;Das46F0mo=fb#00iNuDxYv(KHBo>fv|Mi2c@> z-nRAJX%fTfcs0l$2$)ev2CQ_({px{F;)z%Zj)QuK#C+?qs+r>2DzWXC4Z_-sRwB!( zxu`V__oaodiYKEZc2d2yzt1JjO22>6RBfWBT;T1j=<8p(+=ZOJ-%-J^_eAs}BbPn4 zLGsp_+wrR;#pX*JFc;1-Xm-rDTvG>jwZKTK$&%=dhC^IqqT2P@zBpQ?J@#H)4-FI4 zq-VdW(wY?FWtR_+4!n3&0Dp}k5skRM0Gv{}jIR@P0=utURXZjQ>()G>6_p+jwlz=W-7I zpvIN5?$awOvqGF2g3pdxaCoZ-GqPS|$0>_|#%<%dp<059F+I=D@Tjz#si3GiBIRs* z64hUrYjtC)PFF@~Q)N;43S(*NAZVRrGosrGWzXTQkZ{?drhcLG32(N>l~z32$hgrWs8`JYgoMBaxAgkWGcAr$y>cB5w*fPoIFfH#oEb~3`>-4!zzua-+GO^Hs!BZg- zk2@s$pZpMh3Rj`Q#R6TF*rhd+ewpL5pIM)aSM$iIPC?-gf#m{|VPNrW z*!km~Rfc5A;-vT1Vg+zQ43($Qt@m9z0(6exAauxtp=&jJ##y9f+vDk|chfUN-E9`s zGT@Dsy4d^1Ssv#ddkE3V8hV@Sso3|4o*_@Y77rT#PJaGvsZU(QNOHR!sj)t9PwA>! zLmkQfY9H&S{$&~%Dp#h(HqgbQwPcbnnmTL64?U}z+2e&)Eu z%*H}uxagp(?X6}1>+%%6)yCh;v)iJhW9lymM8^`>j&R`49riMdkP99=EIbLq+On38f6dK@QTTjhBx3%?mG}Hx@66LoPGw-}T`}0gS zew>u#x7S7|KVppk>2L(|JU@!u?ezb!P|SXsnqr!+9!Gkt&tBQof6ow665e#wczw~D z3;GVqzayD-_8AAU?wN^WDp@-Ed5~EW+$JKXz({E8vwLX}jop|8t8=7OT0j}4f{Q(S z*J77m{`OjTxAx$-8LMU;Rs6gn=D{)ImU)dS(e&V2LbV>-ywm5TSOFwn{J}Pv>JwGE z9ap0bC($4<6La2)V`~akS792qBtTB_&2BYzaZJC=vtWd!v*naVvG{d1w!ZiLwV%U5 zqJsTPO#1iCc1mA7lv=5J?s!|p!mNe1d8UI~LlEPKs`&MSPi$yI{0o%+E2&6d-G+1Z z_2y~MU!4v;wx3!kV6?-LjbS=ZwR+6k*XlXetb&IFJ0i9^hpm+8k?5UJqIU6as{BzY z3RU_*3~eapJ*cxz)KyS z&f>OEbX|5MVZ=9zWh2h|C1M6&YsfN5#{9(}OK4bc!`3M*<@m*Ib@+0=3b4{h_fWRVu>oXslf%R7AGHB;aL91P^~N98 zvJamI8C(P_H!U`nh75}{MB}6atezCshPmE)uL0u_+SBkzbeSeK1(wQgFuY(KMiJ#^ zZ3##{yvFkZpD5{ctu z^reUTx(jy+)4i|M>D0Sn-ztWh4H0^aCE|+qfydXRq8__%T(!~5`u!cHr<~zlT;#d7O;Ydk!C7il~G!soF*7(`x)asJ~Lfh{H3Pk53-wylCL@C@$cw&q0 z4=NI;P;Oa&KxIU8vs}edFV1}|O2&Wowi+fpCzQI~RN!Y^{6^ePW_h`D~FcsWacD4A*)7-KnC>#ZWIc-+s7bdNc0yV(n~a0`>Z;6nR*jNIr^CMh_5~KZv8#L z`m>e%3KH<2iUmGL7qSc!5k3w=^*fUMZ`BS&PgYrXZ%!36Yq)6NRjw3r_1$iN2|tJIgsnQa-IJP%8dlh9rRc zM0+Q#(#TNChXW{Ag}P{oICV+dU166SAw+{fv&%smR@2y5HLADT{9E=4oUn;v{M4G( zS9dQbgs~~5Hr%Dt2YQW7fu%neT3{BtyA)&!8i1krbxCa75XpH~p;)#d#}dacrIfM+ zzYf-i*%P3~*OLtXmnv@$GT^J?;^8=JMq&v8MGw zeoJnCSDNqsNlujHHx<)Rf~W@9X8kP@GwOAim3EcQQIiCPzL~)%&e9<_%NDpbk^{eZ z7(~!V+vmkg6f^0T<$fCQ;Vg?E1{kLM$5@DHy!tmJ^kCprQC}YxijQ7 z_V`XfyRv!b)jEjEIzgQmtxswy@mkMaF|mL&%K7_`psvmiVg(5{B@V?kVG7kTFDRHp zT>~N153QDVGujXJrrfxt38Y?*IDKpE%j-cj6=d&**M#Rt)LbLWuDvTFRIjb62Lsad zYtD{$^?mNuIer~#1|FwE6yNNZ>qsn3P~* zk`(Jhw$-{!5Zay=AVBHHI$4}K1zmX!iKCW!{X=@!r59 zp_TuCj>mvjEWSSG`2o6FM~n`lUAgGOG%9M9i$Kef=d1a>4n~?#TP0~bXX?~uYPcAI z7M5jOZd)PNozj5NGLR_f8_klVo++1875Emtcq89IxA)0Z)6Xg8*?{|lRA+ia!p(%m zONOLidGVtV{`^{0SpM^^&!4rm$D!{@idj>on$6|ASUT?8i0@x6>&-Xz)K!O-WnMwU zZy)~LZEWw?BfZ9+BeBj-#009Go)ujJ7TdYlVUeQv1@5(%h+Bqk&aBrLLv)zqRvmDz1xultDE(0 z{2JX~d&LPr4=r9jhblmUIMM%N4sN zyW#CQ!`41qwV2k@4vx4Yjnz5fB$DM(se8$r^H!yl`iVA^TpjnT?IIeXlMT={Yiz`% z3?1!DdE;Ldeiehti|irXwsuJFQ#)J9bNz82Z?zQq1t>x1?uLb_?Vm8U2C|dDLYuJi zoxyO4hU$C1j5@XAd%}7sNy?5KhUoMpvEm)?c(v=5P{HG3;)l$(s?FJQ zW5{E^cJsB=wsDI626C@e6+kt{AM~u7k&m5ON1X*aT^%p429Re{x*dnMcFSCa(s_&i zMkol+f%W72p?rHn=*><4AzitPr8hR}U#pq?KZ=%lJWK>wD}(me1)5e8&1%y2bvp>4C1?+_Ebg$lU-X)+j{e>_xXU%5q_hwfaCr>b}N%0Q#Y< zPCT8pSE&$uZN{(7^rV4pz1*!wzg?!)!*#S@xu=m@{l_2WocH#qyyc}6)~gxU56W0` zXTx;qyTx6_dDY?Dr!IV>)hRn=vJuOs+qdZOXJg)3L|`{mR(vC1xo?_VV4Kj2gi^MR z8&1T&bIFtrqN`u(4tE7LAc8i+q6)qX`5GdXx|iq8BmNFPC2FFZ~a7YP&&!CVE) z`IK2FtkDu4Kk@!d_T}{%$y#~zXx*JK#M&mYDsL6=C@ZsHxDzahUhnxI5|!3v%e;22 zdUWs6)e5WXG%Sa22H>iorK{xz`;s?Zu_b=Mxq0S#ufy__=-XztSsLlP#&w6pB@=h- zZBHI%iF~Jl5-P>V4XZ<+)1Uh@wfzD`3GEphC`&oyyHg|$=$33+FW-PtFD#aEwin(Y zDXlOis_X0uji@%@_s8n<3LVse)@W!>n&qWL#*=Bp!Mf+wGA(NqSkN7SZcD2Y#Z#xI z5Uwj%dI%nx$@YXUuIvdRXt{8rl|^1!oZd#Cmf1)5wv^v(tq`~0jVTW_e+W3+%W~E9 z6)gcZ#tZF>2I-{vVhZQoX=UY^DiT-KJ@V`Gr6IfDofM*}z>{&A2$eu^$>-0VUT}H4 zp!x`%$Wz%(e`bFbX)WWnRVjfo6;L$KrT8KGz4XT)nz^#$vYevFnwj)~Pjp5GY)me6 zIF45fce;AYkZJaF5IGf>7>Gmw-pM;NU{XbPLEHUqz+gThH~vbt$Nwr%SUzvKnO2BjJ+;u~%_aVH9Z` zMBPnNB-X@Qh#k%`)eVeb>h4A2$!foT(qn4*@d3Hpy{G$(?`Z0mUA#aPl#OZ1a%Wcs z9;bulBj9NnzQ({IUkNdhQC%4J(bEb`vY3hI?f!%=V?KBiarMI|`a2q_#9YQC?-dXL zTJ`zUJ$lh9(0&I+pwPB@GRe*%h4bJ59qun*p{;}Kl->rj7s)c2!q$h|h(uTW;9($} z6|AR&bS5DVmv3Uh%XZ6sYIM3a&Ew}F{_6P?>Raqzrq|>h^*2B!>}R9sW4m3?g6|@o zw|)L`gKPH;p@X|Qld*0DGE|(&K+dDQoo5a5FFCLX1`%CiqmkUI_?M_@y zqE5SZ{5|+%?ZMwif)jcZ(L-fL9beZt$+wu5p#mIr41X-|>?{r^O+?PUnl?2MYZ?Cnf-7+869&3_dW^~AI<3h1^p zOR`$v@QBf_PJBw$Rekcy@m{w6$PRLz&;YOwOItdkpP82YMI1puC1>~NVCaoc1a-Py z)9!C;Z%AGqL}+?!*XnvZ(OCRNvRr1(%A_K+lDuJMJV5kSZD8zFp< z^&~hr?15>7c#1Bc2XEnRP(?^+T;-N{-WTxrf(}Az^yD=U@+sldRrd%H=P!N^froYr zV}s`p4eI-)oHU$PKMDZG-aFWDfVfN|O_E79KM5uKXwDOa6b(WKoEAE%d%&xCrj4xM zOXa*r0#HsorAZxNCoGFZDxd-R`ywY-LyZ#6jg+u@sjcoona;Csl}sHZl3OsBZVVE+ zG>c_I8*-o};Y&n?nQ%8TpV_syjw*aTw8jTivqaL0-3dR+Ot<7pRO^G}#M$b+H^}u8 znb+Jn-iL9cvU#Ttoq8Fev{%=*1uoK3GS?PtOyfnfmkn|9#CGlg(j!@v62|s$)`vA! z3)DX{L?eXSmd~emQ;~2i?%NHo8$E3l+p_S`xz&{&Gg-|A`gacKuTJo>b$L9x?cIpB z{Th`gnfVMatSszXO8n^WdQzQu=92Za=zJ|(O|TLEtLuNgb%jvQ0({wtPl?vRVtuq2 zm)?XO0qw6wgD;3V8+3mA&lk22eO70ri!?^s{5hrO(OkC0Yr}6LpYQu<{cFhe!V$Na zt!ptw{ku%cXE}$kIcupO??Ue)S*}}wI1f!HsES^%3TTEoSQ1S- zrpkMu!#CFAmuo!lRqGGU;uopCdyQ5cGs8XFMiD898BMS#B(UVd&P;Kp4`O1dFseVQ zT!kA!r+bHkxGMB+zky|;UM>UEzrxescgj0V!tLGY{^v$Af^NbS8Gn#??X2a!YF}nL zNHC|#b4Tb`a&fT`n?sP{8Uto=Ut(9H z|D|pqeaR73r9t`Ojz9VxT0K(lt5e`>-dO zk!lX#-4WzdH8z>+ zAT%xb0e)c$<`nVV<^bZT?Re~!6zcLs@fl{M(B5YUH(+U)7ZdXcgl~PVeiYkeGs7Qc zP2-Wv*do4VmlL~eyETEi63M=_S$6spNeqXbTz&T6Zm5sP9T%`&{rS^^cdPVzA>#_` zdr^FVCkbFP?==dHSmuF5Y&dS zG|>a8e217-;y)-gXs41-fZrjuv;Q|~U{HpQkdZMXTwBw!D+P)X*H`Ss(e5TqqH}T7 zHQNF=Z6p7vVN|Q&$O#kMJ6-JZjQ8fA;R6LJDSzn+b=4ECsB8{WE=ZGvW^5eMOG?+c zl6)0|*_KO)#NxtDF1TFTi%pBX zJh;5&05W1jmc&qR$p zC`@;zQpdJBfwtk1BO2}J<0;;!Lr4dZkTK^#j^Ib8V}dUe-NMFWyp}0FPe$;!3{t65 zQ+SRO8@?aWSC1nPxy=6_^tp*(H&M)(y6%w*I+ESUtDE&rcQ9<=JT+EORn3u))to#f z10d>cnj+KtA{;y*a6`}_aw(4Kh?ie_W@{YE#mc>Sh*V!4lw@C5e__sbWbh&1iohu~ ztvFnQj)ZHWY2JRU-kc4Qqlw&4HD_+HP~;WFW;(UO$k42|3FGcD^&Bi~`!R*MMTD}W zN)HJ*wxgON6L^Ubc!e0LVf`G|0jUu+SREe3l=wt*At^m;o*9`#ITuzg0mhS^1iky& z-yUtr^1wwhRVPWtF2d&`;Omn(TX=K5ADF8ot4VTJAKd!jY7CexyZ7Q&(ML6P<)pBq z*tVRuRr9Zhl^|}g%IJ^uJ1&QXLqG4big-?;yNI_bV7^r7kfha2B(jVT;3EQp^D>Wi zo@)ELmbJDXvf_K~*%;yDeg5?g@~pk#srQjPEnfV^Z|H}i>cZXl_k%kVX*#H$rv*v+ z)8oQP|LFFAz6MGMNpD_%w1$cC&7e1n0@C#Xy)d!sN%d0KA3M_s$ed;C;Jp8bl0?CW zG`3KCBC}`pOEKPD#SVcLKWh?*5cTTpXD(fImCiU9_U~mBFO6osfY9`_$c(R9UhfZp zRShfJKm5Z+UZ*G2_|rw1^P^sxe{!ahLA&_FqY(&d8gYxW%y$fWyC~%r@Up_)))gnK z?7*B_#I?Y!31vBRKHDL}FZbN5kiai**g9XN0rxcA`KRFhx$>WIN-XP+Y@02q6$Wy4zEx;N@uQx?Jvh)TM3lqtIOOkX zy`4^VzG$$+Ha>3@Pits^kgwu@X0O6ZJG?RMyHe}iE3@-YN#zjj}&Atyp`4uqe5N5ICM%gN`X>lAMoM-HJ*t=IJEQ4`2R2Mey+OHnf{H(!_GzQ zd1YhO41N!>Yq3K-_9lUCxM?w|+yKH3#9wGbP(!Fhap#XX3k9#x5T=3y)+ z%lYo!W%+)AN3Jf76G?S{JbbAeg1VWK01cg)=3623bUVN^4Vh;y29v#!vx00g`rnWh zNZ?vZGTosF&V&ibV@MUz;8tmMjk^zVfgq6OqHggA>Tn%U`z{n= zKqtkWA{{qpW4^LVW9!gRWc_KGGIn}Bj$YdkoiefT{xF8gUJ^O{*T^;MZQ=e2`oJDq z!U%QDxf%I%5=B7)vIG!~u3K*gqsUR83Cb^~e18$^WjH(#Zo@^|I$}#v%9S*3v{il} z=AN18cVItv8K85-iMV#F06=-(>v+H%M;d^2%dA`OS2UogcVL6KwjdQY7J=15yxtUQzR zUj!{&dfG!LFd6CEBfl32%%>X!ib&Qp)}8O_Hfp}uyuniD;p;Qt`-``C2Buy~IhhKY z)Gg>E!a4gEm%h`CjpPJQ;^0XU!AYl#v8;|h87;rSru@s~{6_=8*Mn#`F1w4OzYhBf z=Y|jA97^2DtDHL*m0Oax8myhpNvhSDIPP0_`-iR8hgvejKS&NM-{HPJJ(ZGf>4i4> zwKAvk$?*rO`6-svuAz-o>k=}5fU?2+F1(9+>u2VfF3bJBUH<$8>1qar_mdyhKj4Ah%u0D@*phv_l)|k; ze?UL91IxQ9Jwg?%PqwaSDB{9F@A1h&afXK8I`_%-^xj#ZBrvhV>TGQdcKNRw>tBBq z^+y+1nbXm-1f&i=SFidkwhtcV5Icl_z+>P+V$SE@WesSfeA75Yemk~)YGZ7eT}B^k z#TVT8768W*TaareS6GSxcY9{L(Jq4AZo-L@A=7UHux0|=NryHQANeQ&7YlwXLQAC%s z=*@kldv@0p5@gK`c}KnPi99dD0II~oH!iSnlP?ejoltW6?37V2i@h*w*(|Xq{l2r-7*q=9p!)3r-;daDf zIBmn*=??1iDQ*36=t}m(LDwJUC|p%8>Z)5R#3$<&;=ql@?4#KCI_?|4by$s~PpfAL z<;B*1w=5Uz+9rPAc+Igj+QhwO`H0R@pP|31 zO2jN7ys%UrZFzMnHKPFKskNXLzNMos=l{oos?JVDHWxUE6!sL&=wWjTayf5W&-cln z)ub-c*EV@yVd2Cp$RUEIe}d*e{2xMtK9_J$K`natzT?U=^OSf6HKT#0bOZ|xc>;E( zs@?jbKPUhGkd@akT(SHGapCE<@P^u#nv-Kh%pIrH*TGSLBwI%>1ZpE5xQK`#e&c=f z+-)d(&D~46L#OlU78;6+ohd?VH^2V#R7-Y7ql*rni@u0=%Q^LTnjIG(cq>qCVwEC- zOMhfgzKNnwW4V6QrT&<0GcTJbH0gq4ucD#V-246%KriTp=(>4(@cY1LUn_AOBG2#Z z!f+erZ0jVI%LL7SDMhuan-l4+OLmgAPRFdzxtj||dVg?7BMr!v@txD08mu=_6bqH> z4F;%V_)%N)c}zo!zS1OarVOClD8F61bLJ90;k=_}_Vqe%XY7wn?;QYW^kgV%b3#}Tj1tWzQ>!y|M3wc-n~4CeYSDpuH2s@RW0EKIlYy8| z0i*5(C~dfwvoGgey$r>DrY3)fitD-pjr_}jjSDUe>r~xtL$J81uNci))A{=B7Df)j z?wP(WsJ>c+ePhZ4Ui@S_r=CG{)BN@G2cy{9xZ6xWmtWm~GDP5^EAD3E!tOIW+S3cs znVgmB8hbb0@n@Exp)=05-0I;Hmn`2#+pH1WBbANyjLk1=#(Ez$0Ht*B;p7o5Nv!e@i@e!?no~H6o^oa>)L8>!EJJ55!?xn?nl@Qks6MjWkS7oCf zEw}^NoKEvVS{9N-+w$Ur-DiHK&ksIw4E+I)mi}KNFNSV)qV8ieFW-}rR&KA&FT z!6ayL(11enCbnqT-Qq6k< zhn!Dfp*w38`E_SYe4Yf-*{oC~uFf6=s#eyEk<&@$D8eCYO%*br%QmS78{$%ssXDmr z`e~bFh_$RORE1wSYFkZ}$;^q$&G{518Q}phAiy5fD!22a(M*b$xjnG-r_wLp8+={& z4~X2a$n6PzlSmko9(x_mm~hBFL?C&Cj?B*gVJ??m4k2dP1k$jCIFX#%I>Bi9!C8o~W! zWn~FS>XqH_iaOpn;^>Hu=<2lV0B7E>5GN-X62z;KhK3bwE`=oId` zJekrt1C6Mcj%&It^#`jnbxh@`(isyaX^l*N8--Ml1@AbVJh^fv7D7P^9{sS)^2$m}jc8AiCP>^vg@%;R7pd-MgX|ucb=1td%Jpq?P}H@=@lB5{I=Ch)TQk-$H_1}Fv7M(#X8Ue8I4tJs zPagwy=LL%1gV=U`dpt|nPt^<09aCPUv#DC-d}^_A3mC8D9DMktu28J%-Oj|l7!~id zb2ZN?{AtOI)*bEy1}{e~Q{o|Qi7`9xMbh57dmDF~Rs(hfOhVon8k);0kRs)xqNE(wM59duJ59QQ5KQl8efp`^Px-17b>jEZ{GR1T(!r@d)(fD-<-)9LXE2`hX1n&s4?Dz?<6RUt) zeO=g<1n7=FXl-QgowjREF0o+FQ|d`W{F}vc0^#!8znLR)s`%%}sgE44*KvRl64N1p z2`!C9r#*3$Xvb7jW#7($lRwg02(ytk=Hq z7c2NScIDfiTP)Mg_(ic;#Xnvt<~c2oyj8)xu5}RTBn4iUBvt!8H7Jj+5TjG&swsqFqDaC|wlD9-EZE8|3y7F$O?}ff zCkuLN&!i_%#?u$!=Go>=mA;!frG3PbGj#^w#h;IH#oN7Ojaq+hbB0tyKoy>Ye!Nude$S|KscH`MIk;Px!pnQ~Z*yz(YvX39r zA9??2d~P4CE+YV)I(x#ZxrCnM_R*^k6rfV)24738isjIVl?d0`kMV!gt_D^TvrLOY zU6C^A zi|%d^=@O7GX=xVS9RdqbKzh-Fba#FC@_G0E_HXa^z5l?&g&cBR_gpjQoO8~M@Km$7 zzc+)2SSL%NeYS^K}|fTP!m7JHxO#c%*qV)AdA(3xjSDO>pwqY9;9-|xIXLZBN}0f4?28Ah1CN^ z&y)JbXgUd&{S=5Yzku6O&d9E(Z|}pw%9B8SVb7{dtQpTvaAn28FGxD`%6fzYq+d~v zjAcCLXcoOVaXoGfp_q}M|F~ZQs~v$6VVr@fPHUhh<gjW*OOSJTv#luR5yp{0par6ML}lD|sRs=I zig;!HyT?A^1n}c(axDGRlGHzgu0KY|>8jgN0K&$Hy-kK+kN3aZ?>58|xu-M*YrL-) z&!;w#nd`k3tX}4bRYtmxbTg{_l@4OD2;ATvd)_4!%=?v50Lprz4=+M_1B|uEfSSF% zFurVi|He0~F^(1_PsbP4f^}q4{O}rj?#Jl=HzoS-zZLvl!uD2#XE19FuAO55B)!h~ za}l4mEQH31)}Vtz7J?iMUHvOM@k*gQbbKX4JnP7{!;*LOD}`BX8P@f$;f1m?o7R5hw zef-+KjcmrQWR$z}s12OZQO9v)zIlU@tKR#Qrh)%7#G=8XC5DjtiF1S+7(EqaleSv{ zPPWT~rhk9RW!&eufhR^h5ye{h^l5Vvlr;@O4@vW>I z@10=w!cwMMoJdaBZxtJJ+Xne)msT>HdaKG$@UsXlnrn#V*;h1lDR!ILSy;dGmQrUr zRHj_>8R$=Z=V`kB@P%vZ_{mE5kC%x-t3P&K{wwkRfB%f9iyr%g3M4`hGxrM6g=w$Q zJHaY~C=0#U%uS{iXI};!qcIo}-rb6xUPOdw{0_^Y^Bj-7`7$rt7IyqZGRxvQP|#m# zxw>pc&IBiK)m+_ADtQKN08udDc96<Y~x zb!!QucyE|Tp16lSwpg2*|7~dUcCBR2|JDe=PZs`|A;5bSuTpVj{lUO_FS{XL+r8_3e##u_> z-bBO=FeV3K*51G_9WQP=_{A@FN5x_-UiAPPc*KgS>J%P3REJ9=vm(2^sf)ySvY{>b z@$p@Z-Fz3G3is$$zwn#Z%|vlr09?Q0pXuv=*m6_iK~#=Yq`vz~t>N$biyb)KPU(|q zUACLwR?~Wk_@kW*s5)7h&shx$uRYl1D#14v|GS5~>#sy`UT7wXz1rDCd##7ocDu3` zKQ)aE6=&FyeJ~T#QI;N$bnv~UV^iHY7Z57aBr2cRT_6ISQTEC=`=!JCP}&{?W6~le zT3?}Kh6lD|CnQl{o`-{v5Z`sX^`V%v$L;H5>|0=#{qA*K;jLvH4UW}K^d*m$xd9Xq z@!tCF(Y&IIBtuXlY5R9a1bp@v2|;l}1e#G< z%4~O<3T28l*hHSZ`w~oKOqlkea#xW!Mx@Pgq<2DmP+y{$c1kcfS*I(1mpsurblv;# z=QE2Z+Q}Az zd7|>w<)6q`d;g^c$W|GLhx*`^fjZEm)3)>&WnzHjAWdZhuUqv|Jhl{j*AUQCQ4wvz z0rXL^9vPYzHKyT`BxiQevDHiep;#6OS``fC6(bodO>xBjtlz;v_?cUv{TviZw zGi0dOP}v%`bZHKr`3jtt9ESKG33=+D1@nL_tj!l%oJl-PSCcesd2SIHtHt?akcPv#_izVr`bhAu7$f+d0( zMutL3n2dxMyPh~r9_C`&qD?`70uAwb7-^$d=ZZi)1#pe=s|Le!m#+OBUo6Y{3h&Qq zFLTFwyniL0yN;{fsw>=l(jC(`>FYK5fP_VhD~rblsFwwE3iYkWxsxh3@Oyz8#uy+f zx(>X|_%i>lRo5yu2I?|;*gz?Gz`wttb~ya4I8#{r4MjfBAGiYwt)kUct31UYb6PG9 zRoObmV$XJqk^%H=)Lxg2@_?si(C)CjL1OZB; z*M$r;%voD0b;dTyLWQSg}lVhw3gLKnVd>*h6Rr$}8F7 z(XY1_)U3ZLmPpiJUzy1E#vo@O$b={M(gi6~0j*+B>%Vwd82xQuBAM|4`bek^8gs_J zYCB+`&litZ9&9UYXZ{*3hGM#LKhbWIc={h~syF>_6~9)-V5A~_kZA71dh*R=XQTL_ za4-_CwmEp6GZK;2?Fv82Ra4VX}X*(<%FT!Q6L zt(7l8MMWG@`&zkhsuSS0uv~enlMn3Sg~{E08v~&-nCpdV$v?Ta`^!0;rM32S4V&;0l4l`ZCx7)NV!!+9=}qX-qo|0T!zZwx^8{BKvsD6z>qVm3Ken44B!Nq%4O4Q1wU zl(#}n%!0$C`Y0}8iRhWlAgYPg3GqfClmT4gS=7nRZigl|b3LwC9Eh^E|IMWuXI#em zGPsikZ6%m7<+$olFoLz}FbD^z6^@IwXSHT8|DvLGjlaZ?leIU`z1;oby(EL(*xxC` zgUSrrieXs+!{(%pW{Tz7qKANIr!$|8UoH6GlDrnBzX^v7nt&Lk{5*{Kuhr0`E9<) zXC=+AiJ3}n9)8PJ+U*I89yu+QysMe&#pCy4*j0}mBDc3$8jy*>+EE)z#y17e;q1pY zsIu0Fd!WW3N=P>+J4=jyKI>B1j#QJ(=S@V~uC(@Fl<|MQocxu7og}q#HS+BL(TaK% zbwMxr#wjGsGEhci^^3@tCcQuSi)Qu?$q9te8frapPCw)FpQs|~>Y}c%Q;}Xn>YdWW zNI^;Ym(@9AG208Xva*4{WG%4PC@f+&FitW&oqTw-VeOGJHWvsg3Ovh7cXSf(eM53> zqbjyw;(>AX0Vx$&!>Jh>2GBdq0TZt#Q3s<}Yil7abFhSA;hYf%`H$fWN z?ECoR^5Ss9CnMB?47B(uT()NJI_X>wQ7u$Le~Pw5uZAhMs9+IMi!uylq$B*+mY4ML z;{HI5M2?1sQFK&gVaa^&n(bDnxr||2*dm(U;c)O*0jbf8ej;6b9KlS|u16h-Yua)2p02RWNVWv}!@@j1B=*`?;ux zoXo0DB?77db!jqAndQ_T#p^`Li*9c6j{JxG zv|#4%?nH5Jmm)UaA~s*K@616Y^bu%jJOi2CsYDcpNXeeNm%CRCIv1`v?-(`Jb)PN2 z7z_&G*dHeCWOs6~<%^xpxwG@-=Sv}!$GW;9qq~}o2*$njo2T0viL+bDP`1rugW-L0 za=W7Ppm#9MvdpeBBDPiU-ukj$Y<&5{{O6-R_1-q*$7Gg>XZHCm=?k|M)-QU&VYXnkqokl27? zM_8Rju60Z5?ycES{tkXLhWqUoo%A?2zFvo=Fy7*|d+7Bs0Xiu8Tz|W-1bY(Pr_0Jd zNxZWGFX#Td2LaCKZd93T-)5Qz+u_88mUv8)c$>(_gMt_R=m!uy4xOVNd!0^^L%E4d z$yBG;hcySa2j_2SW(2?}JMZh0rsgAcb}37W^AoRJcPQ#q5xQo^NW0zCZE$Y=-0KUj zzaJW|Hgb5N$bNyu1t|DO=@C3C;LRb2#C`!SCfrcQ$rOX349JTv=k%|lmW0R4I^|`? zZVwE4cMK{%Y`N6y^1K1|iI{14#>{CAiu6pruXEF=PLZ@3SJkFX7+0`d{dip9ZL!!kv#x=)C(9S@s@xLnGVT5*TB0PcY6YIq74c{Jj*lrB zpiR=v+eCD30t~DXeo^r27n5iyee(}gfOSV3kQ{V-cMn4>^2vO;@7Z)kI{hBE>Q-e5 zSL&>_S!N#G`Jx04k&Wx@*0hH*n!Z!V)-T^bu-&U3TwIz(qpxVED4R)py@{LJADuYx zz4Ea6&`=??>&p%j#Fy5X>=HEm&Fd5EvhNfN5(`PDefGPW2big?9eT?%U^xG?Cw5Z< zrzK)$sCSrqpv7kz0<+PfRy1V^{zug3H{rIL9St#RPxZ3fw1jz1d@f%{3*d`X4f(+M zuSpdD=Ty8A|5^;irN9L8<{Dq{uw+K@J7m=o84V$g)(?em5N9c@I5#iAL&)yopkY47 zsXlV;YAXkk0B<*k6o|9UTHX(y)wf}wh@7sNZHGxoJOm>_b5fdbRGzbkr&nb$ZHL*v zY%Oh(joo#ItMCQ5mZk^i5B(oGT0UO?ysa6#Ul9WdnHW34&x1CMf~3OYc(Vw&P?0Ng zp6Q6Z+>tTRhC-;kXs@)MEUF@*u1{#8g42JO7%dWKt@Y6+w#wziHbR?xVWjgd=@6=@6KLBC1llQcWZ#X zK=rNu17=T{fgf!`+@>kCpX=1GHG^R2Tea|sCA~D?rk1G=vksEzzNhSGbzu0;!_1pS z){&+(r=fyU9lcJMYo&<;g8}whbmm+j0)*`ny*-UMYn+pPf*>$XJq&~`;l0c2{gKw| z4dmT9xU{;HR!(GJN4R#XycV(U{W+na?(n&1t$ z|K{0t`)hP46G_CRF>}V@!`&lJPjPQ*q}ZX{lGDw#ZB;nRw!dGWM;ef=&wxM3 zd9vDFlH1GqrNEXTKaSQJu{Q@2(P%Di?>fyzu7*Jra;+f2988H#y@-H!X~;(2X2=0LDPp|4&zQkt?5oVLtczH!Quhzx|6S^Y=ozUbJF?fcbkL`)2c5&;X#O_dh0@jA^!<+{=H0v z%?@Tb8pI6h+bF+{dtqcBL)PwH?1?#fpfLhN%$%aT#~Y=oQi0U^3th3Bl|gT7%VQ%N z_yHTqk5IFhTON2z^R!D-z4RKomm793erhy3(3tIujh;|&wY4E|t+Un%I=H|d@x#wi z@C$b5mTIU>)$cFHg;>^f-iArotu1M`y1?Fsev+B0&Nyz!p45T1?mrorLwm|9^G6FP zLw$#E&AK~+uUrPM;rO)##gr+*mlS zr4h!c4Z0V3WGMk#8Oaei4S1|U4K%e`zu1^*?xK#V{l#u*E+|l7pC2(1-OTe=^JuhI z_FQLwX>a+VCPbv>jsm%QYzC5iM5xKj85#q9`c8=DF71g4+wX=r$#?DN6z~!5NhSb* z39jP(ks0ayL(z8_6M2hT;5n$R)n?j5`d8cD`rIa;w2)9KJUgY#vLUzxN1L~5xzsIb ziyS@UiNNLYtX3-cKmPTf5B>#Qvgd{E95F%+!CGnrtQsHsR6HnxePo4$GC=k^7oUsn zKZcYKCa@H1#A6*-l=;*{Vk8`_ceu5BsmCzm2DR251qZa%0tVInxe@Av-(4-W$|edz zJ5L32X8V+rZPC9*`$2JyHf()fUie=wY-{?K-?#346$Tw#1@+(kx#QBIW)Hiox}k#l znCMM6>-&q~6RX;_rWxJhvsj;$F{@{F}MGTacTK=y&s(Ic|U?ZSV6}yfSUyZg~>f{ zGIa*a{rGtWcuXEz8+P0%rUJ2PVc2=_ALPZ|Lp0;FKuLes2OWikFb|#kG4-nB5fTq1 z6?5!6)@xL7SRv%HWm#OIiR<2Onui-?@9Bm3=(qi;7bkZI_du^cV@Ak)lBXiC)S_xeLH349x_h}R`$a5WB04co zNs(Z~G}V<|GHQ@^s0`$nASLe2hr8$ZZxBkO2w0npDft+NdcETg@69ngzbzwny>>q~ ziWpb3VKF^)>*ReRo^wNfF3td|%;R*t)Ffb5*j(0aWfa)^eMmF+*^PZy;s!+jMw$;h zx>j4LpZHYCCjnwhYxQ&YktHhp4Ez_H@bfo7tQ_onNY)cL*VEV0sed-u(OaG1H~8>X zaestidDdqB-LPc1ZR*2Br*a#_=VPfP1DU?wD1Y-|aJ1RobRo4yCu%4@ql`fJ-vk@@ zNAQA+oPSzW?Vfc`y^ZfT*7-yXdIebiYM12^Vr)BPZEUZC#N@+8i0%xf5rN(ag0jQn zhz)v(IJE9}bmKW>dOMa#Je#Ai;}eKJ`1o)u)*Kqx0~`wt>(cN?k_JS$t<QYGj9vZ~i~{E3h}DLW#ER@% z&(J3g*-sZ_a|@jT?nkasu|Yc(%wZ9|e7rUWHTR#sRpQx!*M=t%w&y``|A-ea zZV||L!tRqdqNDE9m&XTqJsuxVq2F^=nqNrxlJxOxAo+X%SH1He?$Ajloc%e8NaG=K z;9zF5RU*PYVj>T`eH~_EYz|HZ1_Gx|9y9jVwkKtH_>R6w=x%b9G%vSXzCSsLhOqSB zL#F@48fcu9oC#PJkp@;}b7dxmB~efN+?frz+XvXQAQ16Fo*&D+gzz6|wWgA^=Y;%} zAzMe(@$B~uO(r_CykD|3kfNAbYES?3?6g_eFRiQOYlP|rxx+f@5>d7pC53!~_|#)0 zEp7YsU-ud?z5jgQ&nJMtqa}R8Y*Tjo;GqJrk&*l-wtr<1;b*FIl|yE&?k>DoO`fKj z(?X5#q;Tlx?n3d^e+V?Pe`m^gSsJ}EPzb_~hL%;UfWW<6r7*GU8-66BqCV!7opf*`eT}+enZj-%Wv#}jYM6Y*Y~y7Y z^D|XR9w}Q2#t&47)fTCg(`s+%LZ-8WW2L2Imt-zDLtJ5D^|E;54ofNdv1=w>gG!XH zeG7*s2)n+73njTBl4^Y(OYjyQXVy$|VxBK{1_RkX6%psycEEMHk`ErbV1}(wC6dR{ zP}p$&hpQ5w|Bq`0ZRYY376ogQMUZ=x2ALkw_O|%xV}uEY>I)SLH)tUq7`2BbYs@rd zxm?rzo+h-(YIN4SNxbW~KsvfuHTS))TMetf2klZ^hcOx=3rl``pgY!7!r~ZM+6u}( z`{2uV-}qsx!+!u{j8tvkS&?7+F8IGC@D6Nd(Sfk-M#j5GSU3ve!WVy(g7+R)R|iu6tmPjY5s03A~Lb z&$#AA#i&maV~d?|>c77NE#g9+m(?(JThWb}+T99#AVx4u(;>8pX>>Mn#d)MMK6VJ zrrRlCtt6xGJflFqP!mS`+cYWHOB&M1`Pm@Ta>P^iFL9ET8$@d}{zjIT_PB+6F^B77 zYbM>&`fvf!t#<^IR6kub&u)_5GCwfOu-}`epZ%3ng&PUnU2H}ky&jl-Y-^*4YaY8T z#un{+-z2{Ip9CwkvVW)Adznq&Z6%bwih7zL6JZ){Lfvz`iN(hlv6`B(kL$8?#b=ZE z`nk`?pZ$rYI4p~QD$3_z3b5%-b`YH%rb8_Q?%@CVK^uzr>TXrYLEt;liGROY+{gKyWu0;1{Sr-3V=mOgGy)6LoVdCu0>U zTZxon$rltM^_ySm{&Hq(l0Jg}ePyylD!D=BK7(Ikuezu-PMuvT z7uw0kTk~rHRCb20J)0K|SuUR?vWZDBGoJsNQ0_9s-zq+BDhryI%=+=aRsC>C3p^VT zvr1snR6M8{7^Wltro~vtOdKVD_$x{=7F|R}C-u^X1OADHPQ#~WM*8+^lDp^4A_`UqizpmCrZ~)04#KR5}Qmn?_aD9nTTEY)cRZI zmcA)C?WMT}-+uPItI`LqX)#;EjY)Iv!c5= zd*lLc#k&?o1?>yX(W~5b@sN+m{%l{@yyLxsi$RiH^%H;DeH+Zgc*K`V$x+boK4f0xmbacmAQF*l?Yr ziVx8d0IPZ@B6E{frucYQ);EXOwd2rK7DY6Fm*3Kn_}BFbV?i9^EkTU@yqhgaGh|91 zd66wQP_(#UT8?cM;LrPcsdSi#09mph+o%a3rSfAgg8`BXtCgD)KB1jg)H%Xl7y=Sa z9V0p(GnE1#m@~+FaH_ynDQecEgbCie*l-8z9if)q{9N?p=A0mKy3S>}R1A{6l_g@2 zfchj~EX&Ej96H4ptn3FdT~LP*0m*ax0TV(fV((?ABt~!0>On_tg*VX6}mf8 z>qRHjcIE-X6zPt*5QN=zGiF5ek(L&Q7uek;XH3Y+{fYBq?7ePMt2QEfr-DyY@mt{w z2a&)ma0BQuC!cBl@z$=ZWlUQg_mb{^ya+F|F}65*ff!O%P18|6d#!bym{x2QYsX<& z`Lq0I#m|HT_?{xG;;F3YJhkL$LB-hOa`JqMwl{QtLEiq`!5E(EQl^Lcruvj#LbVCJM@Xyi51OmuvYjv6%Q8 zi4nD~^}{e!{k4eFFvQ)sYBdJD^bC)j?IiSBUZ|_^&(E*q>{*oeVHb;o7L8jcJ}+qT z|CT^E;(IoGq%~HMpYY@OKC_QtH(J?D?ezLQ`C=1L3lmwCEw-C#56mXH2bA;Sn8(De zPM=vIGRdPo9JUUsWJ1#cg;vBX?WVUQrU2Osyv2`%Z`D*J`2`gZa7KA7NYK#7BG3cpR z_enDSGHm&I#vlS|kXQ}Go}d3&jFT-Er>BSp@xnEU6VQP5G+lP0s6fNCep1Hq%|BmvQjoJ4RsO zJIifj<#|imx5)XNZ)HEd2*u4?&(7)Y`l|H}3l&E&o~F)y9$_oy-ffG+y{jU3Gwt>y zI1Uy4;1DKq`G^*e&MJty?XFu9K9A}q0`=pmW~iwqv#4FQ3DVB{KK(^!L^tFnLeA*# znhH_Qtz_B1IAC!h@~H3i;K1bZ)Q<`F6iH~sX=GR;!`49gh}XsLOAQjPj#8!u|I4}} zFIT^r+l`Tm2#+v{eyw#CHTvt;vlOe`^+T7DG&-E!!v?cm@rTt2MT9RudiEo#)XcHP zCU*Tjq*H31`}s{b8|iar1FR7o6y5w>tGLs?k1cm;9#U{lC}6grQSY%XnjgtDE+aKv zV9sZ-$OVwh;IPaG$xOigK_HNrc)u$_&2+3x@tRCV0e5V|@7}a-pTl2E8#DfDTLU>7 z7A~f@JgC6u-xs8a4DA55JDsh3LmIt?inOFw-Bb+&O1-SkG%hBaaDsvQ^>nny{rMU@ z@2AS_qy4F61RnrIRhlW#lV0Q&%BP4Ln}2%pmIH%5ViI7B78b?yj)uIA)6&?Y^8Uye zlO54AdFd6zy~+dSxcg$5Jmu_%6(b}D)!Z9RjbFIjVjuWYSqEG`t?q*+zg4Y6(Ro^q zmMO!s&fGfh@&88Om`N6rs^JO6uK7b8U0SST?gTf67>GITc8kEj4(D&6Nh#Xn5&ezld{FsW%) zcDCi_M6}?=+>w=uQ1LvCd<8Y25M``S32>g@FPnIZ1^!bb?21%t|#jsbU8sgd(K zRB?VeO3nd31T|i?+HHP?^IJ*#w-TXvxpeK-Xjf2E8j;l2hYBx&%5t6W(IcTFj6kjE z5J*!k<65She^3{uPV4NNx%k{jv0lm}=yNrv14TDCp9Q;HHQ&wd^SVM2?@qSsxc3d( z-UZ|L)DcQs8QaPhS+@t7s+pqYb+WscZ{cayWzIsn8@qN2?r0W3YS*DDH3*N@6qA`O ze7kt-_m(iejWc~F)oSF!8lx-Omrr5jNkg%V{Z-kPhRyEhRFnQRCjSLlrQ zW4|f@?&6V2$B0%D9~^F~jA&d|Iy8N*78lkqoxE?uofQ&-HmBa~Mq~vs*l8Z-*)HVG zMpBhnVaAwi@0tO1HSbz_P6EMR=orcpPBctnPFdbgPl0%F2+ia8K4w9+Nkk z=hNBDC^VUk&rbt&jomR!l3T~oz^<`2G%4GHD}o-KcY*f?1S&IRdv%1Kh@Lo>L#lEA zBN#b_Y=qQVeqEAO57H2ECs#rBpn;j>>SiZ$3d=j8Gw-On#U!1;J z@f#fKRd`}1o_+Uqw$bJr4tW0+Jr`SY+UyzPDodppPnr(k@g8y$>gpyg0N)n^77{m(?$ymW2buQEzvP`fY@uqv@BkcpwO53o`Dvrdqu?M zt*X{%vRGH9 zjpA0010Z6&1dZMiZB|^V#yrzJ0PH&3_uAd$;0A$>*6TNr5F9!ZY$Nxe>#5xoDgRnperyx;UCm$(Dm`AmQGT!(r0I%G9#3SZAG9_SCtod%biUNQZJ;V ziZm)comcs03S40M06rn;?5z1#{cXR`_-9F1NoxTfXeP*_koy4dn|n~Pk+ zb;x*F7n&an6H86^&ppaK2&go+XnmM`j!FGQ+z2RqNf83XT#iiGxP8Iu1QG@<*nvMA zBH*R`*Hd(UNP7hd6!Q)s#w0Qq2O7hH#wD)OLF-GcGWYtE)my_M;gk$PtieU*dg6fv zZ>4I@_q9aPHHtt9KM*Ms*vB+AYp;Ykep38r-(cX;XAY;se={8JsR3Gz(kWa$Dy4eT zdUpHQ-kHKHTMh=M6$9Xn-XuruzsNR!n*zDjI*jG(vl{-2|0)W9&uX+uyNEZQ-0_L+ zjizls{9ned2aSyG0asV$t2_=7vPIJ^8Tg{{waC$@RtbbVYEMM(qcB<8`*D?(#b-St zFh3kl>bkgHRQxVmj~qL2&LO2wH}oJT?+>*x*Zj=?NP_$9r`s$r=gW^=S0p`JKNHq_ z2OgN)KZF|Z(&&q zrNxGABb;5eSRn#S{AkvFDXjzaJJYV+It5ObzX%-M->c_HvdLtT9qU~E<$XMfOW!%(aqgpS!>S?-i*S>=Xjs4bg{(eZ2! zXc5<}^f-MIZAEJ=EXgYKuWgxuVN*hxK{Sl>&SP~F06~Xkg5FKELQi|WYwD9K@sqD_ zd+fK!_3(DD{Mjn9&i)EMl57Ka;asF;?q@0uNH94PUjI_$Im{sp&K=~Q?#1093nacg zzL`a~xj0IiToW1RSpgUz$C-Dn+*{B}bi%bMo2H;_HNZnjAvs!Ota1ZY#njB*PB?h#fJs(Tm#h>9vI|U3dAqG)${}q|V6QTBHXJqQ@wd8q{*CfiRzW1 zcYw=N3TQI;)|#bV@|swGC71KpDh#3{(mB3@-gRYMF0Gv?UXTXyyiHQoq+H$#(`?oC z^0IiB0%2|l1**smPwf>0hy!~*9mgF~Dt4Q&vfs_GQ_M|F;dB-E1aQL)cjCnn zCr~;*-@N)OYZ}f&@i5>zuJ%5}C104(_nuLh&xh`sG+!z0DT}igg+LmDn9EJbN62id z)MV(M7&H>Zi(Xb9q4cX#uswKNisDtf>hUmQvyB>nWSPmLPY1P~AJd_n!;Nbwl(X_g zWqw#s)Bw*=xD79`=KvW(CfP#|=x0e)+`hH^Y2QG5rdSJYgD{PVljHO=#zBtMCJ)om z(i2S)5X;Lj3-$s1tUe&6`)s;mkkzf2f+R&`J;8|Wba|OEcPsMPHVZpMIY2QSGBKiU z{<0Ed+%lrVX%N8K19RZ4r3m!v3O?}XaHOebXskGQ0J~SU#tpmCIMv4LX^I~n@WE|m z`u=!1IBoTy!qwM*l;XuFj;34f+x37+(FDiK zs*RS2hp#RYoTOJ;l|Fd!cYJPd?tKWTN8QQ=XWGxcK54f9THLe{X1z6&m9{H>lUl@7 z!_nah+RxJ$pdDPA64}M92s)J_G@z-UEeuU*R?CQG9(=p9NC|bQr25bqd+l~ADSR_D zp$uJ2-(8X}AaQOalQ0k#QV5CSVz&AL@QO3@lJCHW*;dWTQna%ZaPrrNUz(ut1H z`-I@xGHk$lze`JVCqd#oPUwM6a3fbA85XX(z5H&2z65BBaMJZ{D=Jsx)by6=3<;Lb zjiOAuWtPG{_Sp-PIVsVB5`zJtY|A+PtT>)11dEx)*v8`Pg?}i+(}{?8K{k|u5gYlJRXUvpLy~%+C`iRB9CFb@a=+C zA)whjPCFza0ZzyKHMwUOEC6Q_v#xC})LA-ot0jn<@Gyp%mR5FL65~`v%Ki5<=w67Z z$T&svE}FNFINGa=ty7lxUSdg7YICpIQb1{ezxcQWv1&e$idE>uF4J7i^CsVZ3PRgHh^Y zn)t{l`vvMbiR`C{dAmzY2GSX%8TMtjp0df!nMYQW?HrE#+4o|ZiqbfLM^E15-@j4P zHlkwK6f+{~Mycg|`pvNm^68E2JX8-@*~Bd_Nl1_6GBD7Hfx8l*eF%ViPEmZD7-TVu z(UqB>s0dR4RV_7%4kUDZt$NpE>KTaNj5(IE8_zA}#>ew)uFPG%rkh3BX~uule55Xe zQ53=eX>vIj807{ixNe|&(iHi21O#?$llu%Wt7EN~rFsZkSZHgO`y(VA1FV%&Ai$8M ziFD`41i#DD-)G$nD1vLXK?WL-$o14NVcKZv%u)Cor$pZRRLrE*IMG9aIEPzh^Ob=} zH8YyS^Csa*(w*7%MYI#0zI>%r=EhONd%*E$;B^^g%q=5;oMF+FJPrc2jmW@ugE_K4 zt|ZmWz`O3Vmb7r3L=QanYWQj--1CHkw-z%92EJHUCA_D+!gO8FMji4P|J{NJB#|M{ zI@S+phTLe{)6!sgGC#vyU}1gM9so%mzZjWkX;G=s2hmZ*%q39X=~-e z)WO+gG-}`Z{OOCO{$CTVr1{v7z?PL=fJ1sfxYN5In1B4Or9NCA-d1>}r3ARqL(nFD zR3y}oo@(fv4~*iX9O%JX>>c%-aRh$_#c}$Zvx2F9;N@MipRMH{`FB9i!1a+0PzI=o z$Zx*Q>E*%)FM3bQNeYmp*2|8OQ2Qq(YYbjrEN)zq+UxlgCj?(VRGlcQgkP2zFZfnG;EIsBv{p>~f9JRcPeiNUIFZ?=CYU7#&A`5?)VCQ;_; zt-icu^fK7#;A%Mf_@GjJ4vRZU|w15&znBP7qbG?*QDcXW?}sf#%$%O3m)22()yOjdO31Z8e2tyk8; z7oXB9oGmB)VZjxl)!l46X3tf=vX-j$k~1A-MkE7i#M;8+y0Sn z2|W$18gav&kXp36iA&Db66rj6v{5Fqb*x=LKovZH5v#~^xqvybZ(~C;<>$3_sg3j9 zO__Fr2TuFC&fndX0p9(-^jBm9>Sah5AAo>QMp}b>ievN}+T8tNF>nNsz8flC-~q2~ z%SrH`kKIU47^3B(`8KVG`8iuAi}h`{9UmJS0U0YajL(1)5Q`23Z5A(&lOO6JA9rsf zo+Am= z-P+Y*so{^UJqKh?X;vd?rZ%LOt#S$eWtqHq@onm_BTsulWb2QRrY$7AxTMbXWHFVm zBD!tiVusyHRbU(5)b=m^KpPocC+4KOmKzPRo$-ww_)&v|mhzH^5t=}qWJqYkCG<2q z>K!jFt>38Q3JbVl9(sVzUQG8x9!1!H@mPtcwHM0&=~+i`k3)e2=L+;$4;8p!4(gAR znY~+7FeAJKwco|!x}%~J=-jCVBxz?_H2k#z!)^rai?kk{IF{pX|Ha(5k477@GH38t zJvdmCOmSQoDMz!~-?SAPW$+z-5DQc(ph@+%@LN6eddE=(U@O@& zB0Ii2cHxg1go}w=SUFgC)qI2%IpP7^67RDcO;sv=@Z*>M36fJ05&^l~dnZ5RZ=c~B z?&}cgpk7hj`VWTReI7(YK7yiZKjGPoj^JSB-yh4{P|>ooroku$F? z5GYYj?AtxonQ%OQXrzLtR8=n@+GV{_W>?t0n#6B#EI=xmGtUPvP1h!O3X1M8qIHYNoo8)rF!KMgO;UE$1GVnQ}oTjFg zU~A?rqEGu5D#Lyotlan$+hP+quhc$Ic40fPwaUo9XRd71w&k@IGw$CR72m`?EeU-4 zc!36bx;xet>5x}kI;=;rcVM)Kb?1%HgkN)aBuC#E85EZ~=K{WHYBh=?rk1-zU6JJR z`2jUV5Jw^W-2|wvf&+sFC_bqUX;g5G>#IljlSNAutl>FrCX($N- z<7NwEgh73VU;eMi>1p)gm$WoX3tvhZILz>NGOZQBLE30 zhC%{Ngz~^v{`mJG|BCCU_xGT+g)b0}3u;7i=B=LzJ z#cef!vqm)gWM0@sgXwr|$r$Iji;lU2=iVSPOE%Jq04kixq1ku6BW#dkPaU1xXjPELse*?OP zgN%&HXgj?4rLjO4=pGl3PipM5Y_nLOgpTdDv1iq8FFpkzg#K=MG31jkg|WM!TPBqpT_@?OM&Oc+-SDAx03B%7us>E& z(NQtwoWjVDj-`J-h>wFaeZ50V=QP0TH3cpHOde$s~*yY}E`5nn7c#Uaei64s5_8nKm{Lw$Wx}#$wc#?Z2 zky5FAS*cWK$Z47h(N&}pght$R{)}ydW(r)%f zY8n6KLSr1|+2R=uo0!PVX!!Rxi*`G0H$3nRAfEMoJDTtE=vg(b5IG>X*{psdX3l&WcjC#-1qmYk@lV4SOYu9T2|eadxLDQ!MS<9r{`^ z##K0=teBOphxT!R4spA$9q z&&SdC=P5!zley^H`ZyPqatkq}g?0dukVCWJJLC0%0saOzw3io}JfV~ZjyLvIri#4D z`-W1HDA^uAV*tlCF*8~t96Eo&4vG>GfE1Phn$WH25o&Jx7f$BoAsa``F&fflokCfw zinz!vTO7fdf%sqlF9)P%W>I?jb(!p!j{0 zBkUnT6E4ti^pC46z!g5W7}r+J={##yr7d^cI}AF}zoZuu=nWR*2dF-yvAb1FIu}^9 zTn7-6<|eF8cOHw}-`t;e-#JCw1bx)MWVjFKfkFceMQR?7OC$`7j5*^)|L#TXsN_jH zRo#>xw@Xi2n$aMBI`R~gZB_p3yfM8BD2s-vJfm5_KOEGzw_2z{H0Z%`0CWLZSs#*n zpR)UWR7(eyL6+^dGc5Po&@LJsXokN|m^C&m%w?__WzQkKMpe86o$%v}5l)@f{vkhz zWQPJZUzoOHUitG*99Y%9-B$W?<&GOfPG)*QXW_eiZuW=V*liObWIOH31>nA@zg%E> zUsn&HhSnB`<>e2bImgKK;<=V!ZhNhyUF5=&ne+1a+n`o>e#R9Pj=8_e4vOyuwA7TQ zQFNyOj;W2M&=+-ESV_={0w6tQ_Wi12+}ER*c-swl zuYjT#y7$J|vXWxEU#sc1xhob(oBw$d-obH|3V_unOEjD{Ej$ws^b*)9Tg9{F%2R>s zLbA1ZB6ms56Dem41QW$n{>}#@-3rV6G!6tX5SO+um2g$hZ1+F5(7eBQ@>8tL0N5Sn zvNtl)H^EkzA|KN>*K+M-RRQq}>3+0RibDvj{9|_L$Qy5*Jt2F)sFS{Q&;O?zLPUVn?i3gH4K&iUh_D0((sJ2NgUq zYnIPgsB5>w>>s9PoXQ8Z!N|Sh2pQb!Q#JIH9l$zQvO8P2%Yr$ z|CsvfuqfBAds3tYq)U;Oa_BZdxX68PJaT9xJv?1HuClIjl9kgwIWkPH{Bd=o z>dhQN^-pTJ`yioUMO%x*D|LNdl*t-iPMdpO+B7TLI<{k zC&#Vl6cbNJ1F`ckdv7MX<6pcHWjoj5PxHo6(p<}jB7hJ9HBZes1B$Cxg!t9K8@Kw6 zu*R%#S%sI#g|R?vkzIpFYO$=w2x=16GBnE-8+QL|CnCa76WjmMqzfd5GQP_)4s>StyXR`0Xn>mKEs3*!r@$!ZB(LA)%d9*d+Z z2P^enqPTey$(N`Fc%4@)H!Ts-uDAS6I|kW}pRI(m^!T92gMh8}7~LjryVa8E)6L-Z zG14lY(x%%1n{#x!gwGjCY%LTmfdyHK)}7^^kv_jZ&ODo9&eWkVk$nI2HT}WQF!DmP z`adiA1SkN$rSvVsBRr+L*;MG}bWG*tQUDiU^wI=rf7oY())b1yt@FZ`$HGIJTZI8e zIAxI_B2qLWM3yK%_=%lB^)~G80vQ#6!z&Q{_@nUQR>K#(kD`lrPR8$G|FcVSjmN%w zzTULDbT1&9Wciet63oRS)75Ndy-W^y60>S`q;$c&mn7(2_@`=g)tfq*9Ksx9;%F0Q z@}Sk0y=t_zG)J-g%}UyItO(%yO&gB7m=u5gb-U~o=Y4k&;Ho{E zC+l^NVU)pFnmPvImd2dyW9L^#f~;T2Nd!p&7j-mY)fY`Ix^i0o_u$REiM%F_g)#^2 z*tL>jN6!wU+xtH*?t!1DT3!P4cVbrIczV4;3yUWEf(rx{<)!X^D%2I9bv;(i61}~~ z-2ktZlaAcu)o}B=M+RUmsrNxB`pa5Nr%5h`SIFT3rkdE#Z!?;V=pj+hOZpefF{Zm| zkI?|f4uW>a?HLee9Vke(enO}IH@J!s zjaA(J8Rd||VvcTvh_Q$=rp|H0g_&W+^Oe&|Fmn<)LZ{sX_&;mnpE21-^GXL1klE4` zUcL3D&A@FZtxahDC=bq>X}Oxwvm6yg$s(-(o>%%AibAtqew-y1kC5HmZ6Bx~VW51k z4em;!Ul{ST`5Y$to>1;M!c4Hb>pg(79RtKio-WWqIEZ41%s9M+7N|TUm-&Ail0<2X zeAIVQmu{O`mSIXu?zAiJk71a|`j`8MFc``zuskC+8Mgg=o5~Asu8i**0@ux(UAY*; z8hL@hI9U2@9tpqS_E*k)8WXeP&FcjlvO{O?rA3zWjLccC7o$D2pB^tD;Px1q5kPkM z_w`}B#sxQSM$*ga+*|f8Q9@`aSJPE$!COfdOXt5Y8-Q`rmVzG1^)?5w1!-G+E5L;Z z8t7wu9Y>ua%em7_jl*!FNJxh6gkGy z5t7~NSHHSirYnvpEQ9+xqL*^1x86qwJu9eg?vFZ}cQ`94&Xy?gGMrzq-Uqz!0bb`S zQY(lPLA399Jr*MwQ`@e7*WM)2cH(0x{d6m9hqB{Nu1zmIv6jP^VAf(NlAbp<1Cu${ zQL7iz2=glqQ5>%7vW3w2Lz;yBb;gCx&arj|x`OFC)%Un;PUUTYD^{2b=y39iyjz&h zXj%S^$4j3sSU00%M6VHr!D(ul9cHU&oRzfRbqAnUq8PR90kmFExJOX~lQJ%ILaY** zQ2J)X>gz>n;Md;`?)-KH?6Mr1ebRX`w7=ax^e^MTk$$}T()|3HgZY0l{wJ4=6dyMD zlXp7dO-ATqRFMDf{*58A9H}~s+ba;b?>yXm?Z`3}O0%#QrT_N`T3x|`_{ zoK~^*o*0H>Z2iuV$$i{|b>Ri1e{ZrGXdT0gPFGw-Xw6ZDHvdC z$TXd$#-)51uP=GgU92?hz1mTVnbdujBs0Na^?A*7;WzcZ92ZVBu;K1g{y82|Oov0> zVl=Imy$cU{rH7H1VrbZUiJV!vUx7&<$7I}v6UAYXS4&ebJa?Sbb{ z4-@Zb_Cvzz2+>C}^>y9BOXY0yQMri39yeJTDN`Q(Pv+LJyG2JVA|kd}>tUOgCcoHA zi7+HBOo_8(V_uJp0c*%bJxWV?-+zsvpuj)e)(y-buWH*#9W7$iEImWAv9>w&Zdx0@ zgy4~$B$j4qw>7WApWJS@-!`0tJO#I3so#}G8tHjYw)6u>E$jF%-4r_18`j$?Bua6B ziA!}Gg+^nU#=p9XW;X{%FSx; zbs`$+z1U*4ch43GT}~;yzKBbc4)^M;U)FrzOuThP(aa_Bz8%8$F}$GnJHctvb3*ZE zcN7I)Pc@LM9-`o|7dlG5KY4wd-JdR)_h3fYHC_#fI~$8_oDCdN)jcE|Y}+Vt0L=OJ zo~xp_aMNr+Sa$y86(cbbC2h}~9a&J2;ku+(D;pC1$Wf3h{@USRs+WI_9>H9`g_&Qy z@Jp)%05uF<%7J1);5+s++th}OT~#G-=DK@!vUE$^nVjlDCi~;KLrVXdM+t> zxBwhloX{`_+55of`-bGPDPP)6ZI9eZVz~j_Tm@CTCqu?mRhWK3*cBGNyptMb#tDxU5+il(M;>$i${^t6d5Fn*`ugtH}$i3zcih!GVKZze+i*evXJ!Uz|}jfu16=4eF&4Yk$ht%>*-rGymEwkJNiO z-MsNV9HK+nnt!xJ?yFv-h_XOA+UsnTo3Qjv2a&=Xbn%DQq;vK0+20Nt#Pw0Q6lP+# z>sO$S%nmDgKwzYnU;k4ysH-lf=5ckamK}gXAN0*ymT_TA71hz-WZuo3ojd|Y=moPI zM&wtATft5#ku@Biq&As**2Q4bIhiXxdAg=D>!o4;o;e}~tT&Y*w~T)wc)|cm{gXOZ zzIgZWWO>z!DCX0v6~I$Hzqm{bp+M%c=f-ceE6t9|J_}Vz!S5wj3XswA?-N!7Qp!-j zxgI^H1TBOt#%}AL{Ol!F+kjFT9SWt^$U{M}{NE)0TMyZ*N4*k(jXb8O4FXp97t`gR zlPQ37Mwx61Lj|DRH~92=&S1)ivi)0V+1|FR0)dL{-$ulz58oE6drS$I&ZTw(^b4_} zQ@@|w=CTfq3RKUnRQvbCBlu5pg7;w;-sTQboxyhiM6Z=(H~kjZRce& zDF-=&-78oZmUc}VIF~){kEX)-5D1f3>Jm%Oz}yC-ZOQSwX-xmS10ucDg_I95QLpCO)A@^PRvISj!9M_q^gh2b+{%ej_Q}Uo zd~Rff^m(jevmax9-a735NayY$SqcrYsS?U6wIJ@&w8;2aFEgt4|~PP9v)rUx=)Hc#47c zS;oOm3L3u&2W130$EGMbh=s?%*&RCD+oSjXj=f)H2(X#Z5rUSq$KHe6s=o~ni$_y( z;NeG@si@WYDY%GpnxbCjI5Wp8pB`9VE=@KODxG@SH-vk7)vhbV?7uP{m}ELkCi{*~?Q#14F}2n#;=}UPZZ)98E1jQLJ*H4LS8o zeoX)znX<~qm{f`nsoWjEGvS1W7}+n4Zq|0Ey>uNeDGp>(HrM|mpZ;lU+6SgCW%`(R zC3@>W6{t45Pucmu!zTcz$7w>S(iNy&LUU zje^p)woG@u;?{rRvMNWK`}WAn6+YKcJ~K;t{#vcBB|z7griIAgV_l)a^Mh8_^ujV7 z@Zar*!ZYG_vg@FycI4+PJF8vd1E{!oPNqjwm5aeC+kg~~;6ESc7yTrwu1{|`*7UWwoOjllQCK!{ z(Xd~DT7}oFA-@f|u!P6iEa1YU&AaI0Rd>qJ}jNcSwWFR%Xhk^dcZ~M)2i5|7xE$ zCz(AuDs6hH5d5TdN=x+z8VfQ5D*rJ*$2B$w0*f8;@Xpu+Cj6hHl2ZPvkt8l!HVlF^ z6}8I+VRe<;RS$8GHr)09ZKw`;I=QbWV2VUtTuHKJ5JgO7hT!4sFIvt?{L0b514h&K zFZ~6HU39g86ry*M!O-Kei*QQ6otrIj-ck=7O&im-(NnW3#%2DtHI)%yiQC&BdNcdR z5NiT|l^}cI0Sb%7T4@^ByHdVCXz4*e?1BvCgW_0`$+6Ad7&qF6bLm7>H7M?whQBVU z+H&Qs*Vm){>?z&#C}su_y8$KLiv6*;B(Akx^y~y|WGc)>vFMTAYVL$@c^b=imWm`R z0T~V1Z(>^05ogdx$R-X(U-DU#_%V6hxpyXn%`nBK45?MaqjiCweEqYrj7_>u;^Y)X zc>M|iO<`3pcGL1G6wdOsq=LRVnxFGR{QgzvVJlC0Zy*p!(f{xy?_@($7BPpSMf+4Y z;SX4sNe#~bt!dI9BOx0(xYyvdEetFbHHit0+VC4~HS5Y7_4^yYX81;p@e%N>k&)Ei zzZ|i2v>>94jYvT?;)j6O*Uki|?88K(LQXS~`7vtBV0J3Fig`Lx^7PbF3C~RqM(RH+X&w>t7Me>=`n*iu>OzLyVGO|k_R_7>wH=Pl zF4c*tv^ssSX1O?^P&U%%YYC($7dkg)EMtlyE@(_}E1Uv+z(Lt@!~5jzMgK5)AYtm< zjQ?6o2Xgoj<$bLAx%f)wOv^T_nfb%a;)4L326#b{AIsaju%P-T=it?AG3lUR-{xYc zvoar42wRR;M>(uQMI&VWGR}uB*!v)`$XAWbvtcxCabK<$o$YO#jqm8E5dYq|NZ<@j zPd!UJ^F?%;%YIethEy>NWmoL>Ve2SFeOC9JA>#dOf6Q~Sw>J>`+-Uwjl11TN=Rlu^ z`Ea}|V=3NSX<3c;62%q92~vjGnox}|9!z$5$yyZgP%yUp^s`;_o|pqAcMA^^e-k?& zZpT3Qalz7G=6KaO&Lst85kw?7BoYnm>;+GjRW<8gcA(?`kPg+i6E!1$6=i_}@EnjD>DBQ-2hy{!4(Ly!Q9*qI1tvE=N$=$I+qJ!{Ob+ zhcA2}V)mZWQpzQ~KwbNKSyB}Md8rf6zm_-L$gC3}4+`{K=kdpHO>M#ek(rQkXYy29 zu={_WKGQMiLh~km7GxV2pb02>wzId6!xC_l#P93=2&OL*L1<#`WoxzuWPxOx8?8(j zv+Q~=Dem$O=T<5R3*avMcE${5F@hD+646{=+P+#RjJEqLD0E@-aa zrY~N5pW9OJ++8kE(NZEzo}+}b+BCaHoPHoe3W_!GYCxPmfjWL&b0xk>El>l^6u*kD zZ!Mm}lvbV=^m-tJ(R@`WPvMTu0gb22v-Txw(r}^Jl2S{)R@ym${e|$FfA98=Yl-dg zqPma5UJVugU0AlKKCd~B+ZJIt=d5|eRU7;$9dkvi$I%2{E+i{gZiWym4I-S1<{a=1 z3h$xiyd77|BVAPX_R!dtJSDbY1&U$I=Dzfb0Ji40!t4$}0ftkCb8!T@iaw(dosF^h zlW$&@2`1Gj4iL2;Xdl2n_r#>OrD`f<*bcymF{}r&si_XMkM4lK&Ps#V?>1=7)nbTN z`>lXw%~q6W)`ntwT3;rV?gP241#mj)1~%rBnCxr7S%F>yfrtffW&LjgReHRNfZa7V zIOA!VpGUHWN#K4Pp+c@qLx!esBFw{LZI?9C$hDe!eS|?>YNkf-T2BGqd~%`Px@T~O zq&WJ1H2r`!uQdUG>Ky8e2*>kbcLe5n|5A0-Sd`Bx+g>!RFTq|h2)5`0HXClh!6qQ+ zxfJumFiTaDela7xG}H6Sw%pnmYE;BU#L=uTQdpe}V9xPr9+;{D8s=~&_=iA`=|c{4 z$_frRn0F9`(ixPT=cq!R=p?oh^)bpzn8+E9(^}|9m?R^UzRCWoNT)h)9`}xh|oFjbSQMocQ!h-zGHX}f+W5>;Qpn0Y8 zOBD28E^@>~qV0sotW%`_;mwU{4>1iXMz`?ceaV0cfQbmf8-tpk zn;6UghpGS*oMI;{W{4 zz|fiCW>ijGaAUNjvVpfZGeHO>N^&~be7UQ0=E@JVx%C7a6S4K#cL{P={G zfw~Wf^h3(~evf(qKEC#2;GgzYB2|U=!8t6ruJwvbu&ed8G`NyoNvV=~>y_ewt{5#x z*0H@r#*vLzPmYv8OkIRbXi40_d%A=tZ{-{YLb5@E>_co=+*kuInqldnE_qn#Q&#>Q z43<4O_?h*0N3qLS`8;!b2Ajpdnv2@Hm1GWntq5?5LY-EqB|D3iZagD7>tr6)3=2%a zs9Lc69rNUT%UkV9?rAQAmu;|~$wiGawh8~BS-gMchu(H9-O^Lt-%hwJ5$^Kow1&fF zBLirt#!Own%|ejO@W$jNOdep4Y-0c2r_7%dAQoW(DEmGryQ8EZ{o<@i33D37G^R32 z(oo?FF5x+iGw`@2*JF4P^MU!(Qr?DzurEY&MIi?=boH+tJ~oOh+j~`4+{dI0K;b8N z**QzhlKIJX z=}RmJ2tv|N~>KsKMdY@-KI12Uaf|r+B%!v>$`|d+Z0r-O$xOlJr6;5{{lDD% z3pBcY^D7g6TQ{GPBD0>=*6fL*F-tiJJ~j)8D`T$g#BIs z1*99J&&o%c z1Y3}S6~qN-j%q(+^WT~Xdgut5xqhqnP z?|#Rlgn=?E@G`j};q0`(Tu#_eR0BMibTDJIlhy3rv#G*$D7Van%(e`-GL#fEj-sP< zDr$-dRnRG>&y*0H4SGifuk(ll1Qoen&Yw>sY(CxuWN!PVQ)gyJe7RdPr?#|_S{K*KQDG|Je(yo|6Vd0gnPo@$mMkXaQKj%1e6`7^6uUtbduVF;DMOMpKyq}cjm*z z1H~L2yT_~45xI{q?NYJ-@mb-k`ZRQAW-h}&E6Z5NMGW)rweKwcGa;g0c9DYpa$j26Yh{lyQfF$PID^0J0-M$n}8_hjUd1LR|I$K?$XN-i{5fvfCe}b zhlKSzLLV$Oe%{a;%)7*40s1zQ04Y12{XKhb@X>zZr2pJo|J!C#M6vE$JgG-kcS((F}cgW^i+OZ0YBW&^>*Z@lBn+4#6ALf<90gpnK1Hk+gxU|NDS0q~c8R6N}~KG7}JRi==Ln5A8Cj$(o6u?!U$+laTiQ;r->AASCR= zEn;)bE-)`Qb%G~M$QXiip7L+&fEIbMjAy*io9)V+Pl-YXqD{rXGu5%_{JfIxSb72A zM)1ruhJcDJ`5&lgx8ZG~PNw`PM#Vh0`Y^NX+b!BIR3)|#w|}II#Et(xQ1TX+b$TYm z*KCX3A$Hu#Q%2(K3&2{md7t51Kz<`t#z%{51{%Ff7`M_u^2}vH=D@>`Tft$Ou*f_e zh$GNFviRE227~{aR#-vG8NSLp|8gqC z(P7#mjnX#OL`isL=3qgM;rSTmoVwbD;o}^P!fsOZM zEi?0ht+``X!Q8Q){Sh%5VLEyV&ncggpV1(!V>IHl$COhI)bOvQVnf72*ILB?ts-{W zo{P)f{gE;Cv+~9W?z8{-nR2nBMxwmKkDQrT{#56~;$H^NfOf2RyDtP)(ThUTdHxv0 zxfk>T)UYYah(PrNS`YHCfxin$wK2ayAz3E?NtL=?{0g}HUv8LE5#V`K$fSL_RjC0A zUq_f6P;&>{S=OR}bNn(|ISPp6Taq)rrf(it_QPezzcru(JJ{Fk$~{EMczYX}FJ@Qe zB*MX@TOdKy+=CV?Q_VT*dG(+5ozuG}e#i$HtNBTIqdjA;Cr5f_cKeK7)-Yq1MX0`G9x ziX6MG&AxKtPStHDE->H}o<{6@JTFhF3yrg}ZmWla8iNj&z~5c&ZXHeIe7uD9&Zh{@ zYh9I>ZcHx)tEQI_c%`P7?)Tv~C)h2CoB(lQWWZOtyg(O~>Av~Qug=OD_KH9*HfR0< zlrhZlBgVN?Lvfw7%H{hr&ws9;eRw1p?e%@^?u+g0qK*~Yg7Z@Us_*Gc!mqKt3fD+` zCT+~56_N5I=u+Nt6?SMEU73O9%m-&(`nH0fEcr8c1vI){{vYrcS-PMn^}aqgr4(Ze zNeG*w0F8!Ph3&{yP8&LngI+38qJ2;PtiZKVz5Y`;Bmce0$~RlQ8VrPOoj?SV#XMhI zcYR4E`2_M9kf)!&-B?{XLFnZ4n=!nT1;J4mK%F!OJvC2F*nD?xa$LDz{*Lm|I-mDj zNS>20`xMDv8$CyF_KP!yesVgdq>hoFOwHu06>srk^Lz#S58`H=LePs%<#wK6;~=OP zBtYGwKQ<-B#LghPcewp-=e3*EE!-);Q!;DTDPIDFbIVfH&zvmItXGo(t1n8~DP*y$ z6o>scoLXL6OkeytE5-Pztq}E4?<P!Urf)O3^aoZ-;oN-}3$Wv|xe6FAo1zzg(| z^`4xbF)eN3s$IoMYzxWaGkWv{i35N<-NagWX8L>nL!JTP8(AwoK0#$w!d&8UHKkSX z?6u@C@ivFFO%KOBQwbE;tW4==?APeNr$ZMBrzN{ z-?@xVkEDNHCa{edCj4Z`1YF7aI1jF&c4ph7uI=(TD(Zi^96TednEe5^qL71ASSQ7~ zJWN(|t}4h1cS=Z8=4)jrhL&>SBP*Wb;TEdqo=VU&R161nT%jN$p0_hwU%pV9M^V4G z_!u{?wLEq9q^UUFQyjD9+A_!#tntQxux{oObCNwwx3Stv!@0aNyesE!X^AjLuyU~% z)nT#OF`lt&uVj4pyjIk5SQ@DsQ)6fQT3kBacin;-3uoL#C@;qsRGw#02t43D0(L-V z*59vOnMkCuT&Ku>FZlYQXMt= zJ8*kT1^@z@9|aF-6P+09{KALb9{W?*@Tl06E`*w#z}Afm0k3!1Y6`~ zW)!>4-!CNkvziUgMGtAb^=}vS_)42U-}E(|^iAK1_pr~7rqq=gE$lt(Kfd)sEgWHX z_gkEmD6KExAAQhkqxRQ0U;>7poLN7hNQNx^E*afqXlBNJ4*LslqXtxdD`)M1 z$;AIJu#mrSP}Chn*sX*zk^yb|ARPohaT!4)BZ?s!IkZm)sNO%~w<*$`NP7MNDd@M* z0-DxS(Nl|gqHjv!Gm-Ux&xN2q+a-yg*0B6!o5?^jjn>e2J0mm&&{9<1_dC5U97*R=dcoo}&d3w^t}|lntU86Hxw?=I zB?@x!EQR)=s9a_wirXni9S??EubLs42Uvo2PONZm7QD!A^`FqO$UWes`%$!n9sv6v z8a>uomA*-oUX%u9)VPr1FH*ElBHm`nWb;CWAA&KH?gmLGh4Yvv%MS=-V!E*W&WRV! zAy4&vvqw1yrE+(S@s!d#JVy@ChH8?ER#4<{k`zNj`o?MpM$w;9=zCA}y#(gpv|LxV z$frlikR;gD6is>Ydx%gg{@Fy@Si1V9&p$^KvtZH9S7ljSXp`I0+b8Res0i|%Q4un7 z0c2`-7ru=x|7OCj} z`(oYzCb8qF9s~;cZ(3>7KHmFmUym+sYDp2qPrxAt(14;IM$(%PsP`a%TFgNzC-19Y z45l^$Cx#Imo)%SO7g~;HLEFn^-3UGDMQUA(X*g#6_F`Z%;tyNqq?m}>)e6%7_~F2gG}#Q6!)Hes!T0@qWFj9pt6zm|s2P~bTVynW5EazFTsD8blckd> z_@tOA0O&xL{cZ@y7Vyz@eXW{m7x4KSEQmnx*Y$n4u-2nx1ZVouqX6_AB2BbT1z1Ei zc(Ul)gpzET)&PPnm*2Im@Lsyyn;$cJa^9TIku;RlRT(YNmfhdp50`kH8F!dDri1B1 zSbrv9*M&QGQE}DC6jhl|+;QNM5XD@&eBYQE7e<+i=KiSI-*;PmRuYJ+aE> z7Hq|!vXV%DsfXOj5JU1=O=sA4ya{k|H*daHQ%GNOvvTbqX5lU4m{-R5TQPYd;ru=p zM!i@%a>x$M(l0f?PhU327mjW7PMq|l=XeVEnx1sd!0t90oFj-uchaBUhNZNiH3O{0 z%l7j!J>IcLHEurQ$B({qI=p1#`G6CF`&{u6bJLCKkDCwtJJISJzpsC!EC%7E2YnH5 zXEu&YX~El%$o?!?(PRz2nECP=Ys~j|>6eicvc?e)p;LZ^ZA2~mkH)>d=@>zL)k>$owWe37z`?iiJX0sju|9Bq}0AH0*TF(6}Bd=uz>Z zxz~c_fIxUJ{Y7g2Np&3G_gBpWl~y8>qHN)ucZcLOH*kOp<8Z&%DA-3PZ9Gda+69wK z36asG-TUU7G3{W2z1xVUh>;Ud5qo9I0QGcr7t~grl$$Iro%<=swD)Pm_8P#@q(%I(>M@|%2|;Oieq?|) z?uhWJ*FLGdHJa34Ngl1O{smnf!tQMuUfr+b7rlz!_EhozO5$e@C5m~LJ#emOFH#Fnm zZ;h6pOm_#a9i_xbb^sr3jdO!)^H^nWfRM8~B{uJ|?^FPf#RyECx1g-t!%;yEA~Y~H zZKs&DMR@yWcHJ+~k$7zVM~-XlzTT4D__%Ic^U*fhRT8ybt{>+fFwgJY7vkp!d;b1M z>Fb;naa!qd&z0S>g%P;u<#>6ePj%bgG>e+%p>qZ?0lA3MBj(^IR*NkJg$30+fG_&e zKV>lrxP-A}E0(-^@@X6ui9<}Htn^v7G6CkZk?hW*~OZP%oUm2vi1sm%}dsrs?QGzDv#v%jEM~dPO4c zff|4r<#&yq2Ze)Hfr5)OHSL65egg9ma^&S=Vf4J>0pUd4B9=$s;q?`KA0MbpjF*k}9 z{r&hTJ!O$zdi<{auHF9QYbK^IMc+o=CXhE_MX`$(p>Cm_J^yv zx&2NTLZ}w~*YN%-YZ%?5Obh|EtLK+D+*r*pSFb0Yw{QiN0(6VuCuEH`Z&wsu5;TCA zqg=8wf4N5RO6mXtEMdB$`)^;hsnjo{@k2Yv{VO<4PRyzvt=Ggbm47YvpkVGX?Pyu+ z>VDLWE&|GM&}J>U&}8>3+OYL9-z&~5e|jiFY{+#VPf&E`P!Po=?CHl#-m-F|aR-@o znXS?=0MtHR6j%YTP_Cq~R$0V_-d39kAbg?a(w=+a6y^t5WHdicve8?(#a8T2><#qx zVloIh(8!>3XwRO$3T!8JeL3CYLwgG}q%hTtNch&+Hp(WJ<3shzbV&n;ooM0;Hc+6^$m>T@O@O!AV+BYKk#g-|K_y%w&G^s&t z4G#ViVFEtH)1i|6QqZB%KzpRqh?~~vw=bel)$8+~@=L(TpyYI163_a6zZv?%B@x|X zW0v<6?>@txYkIDVFR?+lKo&|n&}NgzkO8Pc%LesnTjwKav(&K<%7KB}))M(4?b}X% z0~!+bGgW{p0dF`r?PR^4xm4Gn2HvRp|2iRr+bvh6|90`F_u&51UAJh~B@i5rsp^?) zHLvE9N$|^X^YL?EBdeJHmsh1Kc`yP{KjgcrS|hR;d=YNr3P3D>EI|afZYW86UTp%V zLi)Z`l2GIm+=3G2ni%k3yDZUZA3Ry6i1XW5%W17&Xu`L+?7b(iqo*bLS>H&T*2pH$ z)dvg!wWh|+(0&0hn&fE%p}W~*vc%IFIuTrDII& zC@XeAX9XC zNQDINN$nwVM!g?aGT_p6Qg9;Z!3=ufac)aI3RPa1xr%&t?M-2-!EMT%t&Z?d_blzhzdV6A;J0Amx(NoOs7 zrX%c5Nhkm6B|0~%JWW-Q#iAH)Vn=^NOo!a55dELs^kBO$?ryoHHyE=FeC*wwC8}^< z<^=Ur_N6N>Lfg4z>;1sPOGo?OS9Z}$n6ITpI7mnK%LTZ9W2X8#}_1pMm-qa(nsBWZF1mr+N^dB|yXMMtG14I_BG@Y4~&C zf^c&^kb&V?#PE>P=3EYiBh!$`IkiJOP6<@W(SMHa!(Pw*xm>&Il}ISts+KojtpY&W z_JugSaZmS)keD#p|H*lCbtgghup2HoT>R-OuKb(|vW;^-gg4df?p?Z^1E!s>6Ll61 zxS$udX-6oQuT)Wzrt{`SV4Jb2cOcnK6AkK%mgcN1$>rZbjVXbfNppy`wKVTOcP&}? zFpTX(!vKo)#%p*6f6_Ii%zGpER9X`4zISn9oxmlqW!|+3@Zr$OqMYocQ}_}&5b<#J zc!NCke}Do>tXfj;7{aoRPzZTEM-)dUxDI~*C#5X}+Kch?+uy%?Mhi(KvcWoZc76aeWVCG;?Ty;}tvn@M!{&cx zzVw)T%;SatE@1D`WohSz>45J*nPcnQ;;1W^zo|mw1%k6OOt38Y-@x|OqTSPJR7xLy zH!sE21w+rW3YQ6C3$8b7&Guy(yD=?T+u|;nlP~U^<$sx#qkjNu9#cLl1ACD4dv@O5 zMFJl`4tSmWZt?FEtrSi0pJdZc=c1L&Pm-1*=LIk7@xX&pv{yE=jPl1ZNfcM=9Pyq% zU^#`IL+l-ci)_8Dg7r>;38;C-c`Hm5OmNcx;=`IIkI^u4NQp)Gcc`!dVaG6*#7*GzGCNh%2y_HTK+vej_YnfRdE}o!p@WiKJb8`0KnKpVy<@ckWTlFvgaFQp#H)9|h6@rbDJDOxFX$(d@M)gBF~OHx+N zwkiUkbpb_n8BUT&qhriRII5(6a;v+iS=O}WplHhPL!Uj!-R;rsLD#}{%RM0Ugj%M< zxmRA?a#jEdE<&sTnECUOyQxTo?|ilneS?7ooEta*=BJd8eXmfq{R+LScUUfA#EUBG z!T%4d)=Uk&Jc`SqnBM64VXo;!2s!{dSZ}-0-2R~Ln`O?{9Oc-IMHxnkh5$5u)f$ z3|IRj!xF1@^fQ;DF*K$&zpqMDBxyWu7YfLRz~G24W@+}xcN~Y{m1=xQ-H7~mZwF0W5J}g2ZMv(DED4v6)-kYDn9>oSYRJ>ZcZ5B5XP@C;nL=&Na!3-qF5=_0 z&g1u6oTdBovDX!>LlknBGSwLd#4OG1;+uM3$f_#uYpDf_1nUZxzslBqQPxvBKUC}= z^E!d@MLbm&u7#2P5G3`c$0yPPcjkSYP_d5$ z-1*Y<_?_kU-SVJ{dCyL^5I4HNDC}_g*Ib3@nK|sI6$73HqnBlNWRjE$PXd3$PoJF@ zb6|EbFJ1U3$`3x!R)$W>xCp1-`KybsLy5!E+@g6RdB6MnZ++ZY_)CuDYV;0b$dRVR z{#Qh=wik$f;YnvI3kxJC8~H6mqK+FC`(6G$jtOF|&(E6U&rS^k8J-7sb#bId34lC#a z{`8wgPRb%>?~y_K$Ro(y6Zysk9>lOoq<8cElq87$z*&PPXptI_Wc5`$KK#* zhBpE2A%rD(w9JGq=xvefm-VN#ds!IzX6E*fxI|&VIwpqRxlmwY2~~QEGN9j~(&7D+ zi;%aEdSI}rb!!&-)A{N5VG5wjn^2d0$aAgR+I9YI0nU#r@oG_jdKAfIuU?YcMYTC< z$0aR{W&2I@`-HLQQW*YRy}szT95J%@p1V!v(UL#Ms2dfODC zbCK{n#4DwB$Sa5RVIF5R9kJOU?;EW_t(xss)Ngofu@qR_xj$;6 zFxLr@DW$oZ=d~tPG_JL!`j^PeF}A9&D4gU2dAH%-dxMRs1~u}gKT>f1WUVHT1gKWG z>R-Mcm;dnsnDI<}%zd*P-X!_iJ<9pB&*c5KPoC2B1>Z1FI_ftiEM|iA#IvgWPSd*w z%EBtXH|w*jFPS|zt`^yh{^Hv7tJxRKSycQ^LvjBTuiFE-;p$xN^+ahid@xr8nc+F{ zL-y<~iE-{m#{!;{&C;#gC63LXoE#Td4mA8`+exUSTfO((*$To#o7;gMukSzbq-+;e z8%X_L)y)GW0-aO!PLd@^4kF)?G=eo0q-%dPdEj9H3~^|TAYvlQXgT^mjmX6-{3yy_ zqoGoZ$es`h7R*Ho6OJx#K4D8DXoVy$W6qj_OMhcukG#x#>Bvx(6gy1FV!_!9+s~i@R7*^k?&95Kdo-+t#U)v6yPd?5 zMiTW_e9T?-XB-z!T#lB7`2)Hl4sCAxK5w29*F1Kl83g0?Ffn&gJF3ka(?i!|D9STJ&?kq4y!v@XQ ze2Y*Q`e3!YuuaoYaTet8(`oVkqRgw|55hWi_Ld5AgW=$|qwmpibvadD+HCTNbJ8s+ zia}iwV3uR;VUgY8IZ)aH>6^bYvm*~T1yaKesZv;s1KCTlnGJlX@u!Ii@rm7PmYQ36 zjy8Wn8C}WL>?>zHX#O;Mls3(zdfD0-nwaww{1K`(2iEJNCvcy0nW;~^{NDMJz)veO zr#a_BljmbLDqI=+nL^UR*Ypk^Z@DcaW3+a9aM^g@ivWbS99I!9lh#_7mnbGB8G=%h zqwao96#ZVFl3mK>$|)CNlU7gipEZ7SA~%ktDr z-~tJZvs5CoMH9sV{a%s3t@2@ z$dfg%Yj2h+nt_O^=&%eG^A^Y>3{Vb(ql*`N5a+?>qprH>BxQN=8Fb^riAS9!Z?}nKn$Kw*%1tEg3?!|w>r)<#A9zKA8ARPk20VMen4Ri;OrZHTIO=LZT;J0T zx?nri@#nOkghViJ3$b3AxU`wHD49Jui%c|uaYf@kKX-?5jx$KeC(o{pkYwM$1q%Q(nf$iqK;^zGNZiIBoD1Mh`S9-D)XV47s)u{Q%NKckWi9CM&_x%+^`<ttSiI5yz3w z@i>NC<2*u8D}3FDZLS(k$szMMRMHD8OzySIbm`5fSiYaubDgLCz9`H6L8;ka=d-5! z&z5<=&w9^o2qtxyR4Li+cvx<|h5ViRl|+DR)(B^%BRL4*JINJuq>($n9m|U-TehIk zZKzB_y=xMHg(67}wwvg7&i;s*st&a{rNFqSCyAmvmzxRl4!_!U)cy$_cZJ)P5AH-FXnKeBqs;S~mr9!I6c6ZnW@Bamay%+u;fz+Iy0I3~ zhO0XyeG9WaBz=6I$kp#~hcjMK%WP1bAKa#uiw5ARwVfgXiCt4mHW2e~A|ClJHB7z& zO`aM=l0$lk+rNXa?IRkzDbC{t|Lu1Z)2;sT>;_u!NMZ%rSIC|l&~GOcfAN=o!E^oZ zAV010>|d4S4;Pg6ByE8x#gfu5KY=w{-%8wq&~)eOgr0PIJZExw*@l~+&Y5)re!kprK(3lA z-SSPJ*DpA7xh`n41VLNsZm1y_)D}{4gQ72on>G1MVy1<{(wN6O)SNs=S{w5@2%5!7 zs<(195cAN|Z03k5!Tz-IGpO_Rg`cprhD)?1fm!3nk5Jr_X%WNAp=-}-cb3A0HjC<5 zQX2L~{C~c$!bh2O*U}!lJ{I4E;O<)T$uHC1rBt_dQ@bR2d-v8uLi~Ms9LbA52flp7o==Kwg9pcaUc2^Hqpg?9ZH$E@nhtcK!vCa{xFPPR<72@D*?5 zSigR1l%kN8bMa#+9NyV$gVcf64@l>W?o5Wu&tR5|fXqvdO67*D4e^ct7~}5Pjwqe^ z0Gn8QSZEku+MI@t=fq!UrGBfYDHK0NJvTplwoH&CZ0mXiTpAL*AA9mpQ~T82#RS?q zNt>QA!;Lyh23>=P)b>P-g%J}xb5qx7ybVxN5&<=wOI@3jy-!J|UPGmUSP8&f>?K-n zSWKIsIo$fg8ft%orq2lzu-$k}zbA zE)c(&4b|*sDZx zhk65vP7C~#fxln6xn`~Y{u)rG(wM(HU*eYbZu(_Av9rT!_e4+ScmB^cp@ycrb#L=e zh0>SbwItB&3L4OZoC{|#IbpuWUROrP)FhL&;`{ds026(mS^9K%ERDf8xAm~Xq{ zmlzi4mY53p>H)ies(CbpvVY!s)gshur;(7~`S*SkPhG!nl2^IE zqo9LvuQW!JdSw8i6^*$Tllf4u#7x^mH!*3TC0JQ3KM%d_+(=cA84~zLsY-E?G$!cU zQ#90?$dqIjR?L@sa;Ez0t<;ba_fEB(R7D(zTL9zzD!#4*?#QOza-Q2|#ha1w-5s-$ zRLTxqHI)t7F;n$ushw@vE%bsk4UWtVVjX`|X=x72@*y|9`D9whO0{0xHsSKWEfdDO zc>OfRANR5gd2<_Skq!U+z()t{MzXNo7Nh)9opCoawI5?}Cfsph0ixH)({Sb3vL}un zkyU|vp4#Az%f}g<`K|UUNu^vlqmaEa4e}Edd>MJOAULoNwWD5`Q0?0yyy*AcIIl=Q znJp@SEd9!ct#SYqn<2eBm2#kr7^Se!YNFe74q`AWHQVewa->gRERL<8J~=z* z4mYswUu(H|6}1xetHV&8OnpfmnfU51bn-3_iveA3iwWjd-o=>mOJuf&%RBoM#Th}~ zI?L7O4wMfMk-RCp!%#wh;p#z_72c&=1m(mqfv%+@RW$X`2HA%L{{(fI5 zFR|~D_o%24unV?wWQjr#n@@^!X8eUUdBPZiK9CB(@7mroelXLRic@MzKL%qzw!ZHS z`}!$dn0v7fMcHaL$!qBuo8kJ8<4&2>;}CyqX}=D#!O_tep--u-ts_n-ZT-J;6cvh@ z1WmlSwl`~DP)iyXHNZJxfi0BIXJEku*`sim3lrcVy!MnW3(c#>TE-#~~f{gGvwkChm2zE;aGf9jDmt{X; z#*y`@;jHNn=jCmmX6i4z+@XIyq>KsHpYKlVLEcda|LHCg-8qz{7MZ>}D_R|mcIA}% zws;hm3{y`yn!Cqh%ylo9L#*yR}WR`fh`9*_oEZ60%^ z9yi-fo2R^ym5rWi7|0?=qja{Koa;L+xfZE8t!%-F;%*bz z2e4&?5C^NAa6mNT-AH-u@n~YzNS^|o(wCq~$NiDr6!iXq&_ZVD@N>;7=Tp&CRnao7 zo>@mt0eo4o`@nsxp8)IAoxJiN{KHCf@n^B?cm26XwV}50GIB|Fh)mgYb1t zGB1Zs3fIMj#(>;dNkv>g9BLF6ZLZQhN0k*4xyUB$mI1L1Kv(Ck5*FXyMOFVU2ABF@zLgN?b5@m$Hlv+q}d2mQk zC{&jvh&@U2;)XOQVf!C;u4{ixH2Fw>-?%mCE3108k^Dwh`~CT$X!F6OTr%ZB9rM`; zr1tb!;A0OVS@GFTto4oP!@WQ+m$tP#I#(3js|&^v%O1k(#lItg!|->gTR|kJ`^w(4 z_tmcEGb48HAxfon)c$IE*ZiA=C>Ny4>+?J?Q8j#Ctv54(0Mehr#*yV1TjM<3%=&Y7 z50uKdmep1bi`fOn^BqnfU0$Y2p#OBCq{zGP+i2XxvZS%=n#}GDsB7C=jw&2ca=w1w z5kB@y?Fqt4T>-}Ze$m~|suG0nrre9%Dfq&1S zMmUwKfgUK3{=~?g4jD3`DY)CXlkBFnI_n$S>+!EyVD|Nq&FsJ1-D<7Q5*t>W`K7G+ zlWG$$;jL&@xf2u4bVoP0DGL0#lyRblM0RacN^?>g5tQ=^M$viaBZA9}#)9xlX&6BA zA`%G2n!+$Uof~|1C}CxGw}UW8hT}q|Dl#9P2RA6QSC%3ROY4BnQOsd=&MVaK%@$3y zH^1i=zRuJ9cb8o*znd{bu~TTw`9(K(D2A$zbNS&oP)QDxb$}*#;Yl9U^*?z>@#ojOU`jZjwY|G)kz0Pf zZhl42O`ZE$NvVt_neZm2XXjfog?0L?g+-`R*tG=ji6X!e{aQSA_63f_2V7bpk37YNF>((Mbh6 zLjxG35x~3(mAEPceC$B-lpZj51Mm{cd;b6)qOjbC*yiDK-hlpyta=LF$%?XrT>#8f zbHYgZ-Q;bPM~26=y?9EJ&I4D2sFJRdWBn9u_1#*N*T8Syx6~f2Vm{rBL2GqZv2V5y zK6Dd`uu=tRPHbP(N|0RH78dh|VE)VH+*V?asi)o&mvq#|r?tJo1RMu#9UuhaKwX^q z78IK^?hgLNVU%~SrMGz?9hH7eSqs?pJC8)dlU9^R#AW|9;-+$@#wis2;j?Vs4ol&^ znc&>{tHe^*bt?MUUrmhl=g#|T;Xc1pJ~eIeu)S=B3G0wRB0p0FR6{vWe$$}ddB59= z^1Wp3{FB^XcY8IjH06ema=Yxs@EPffXOz+n6T@q3$C(HBqcI~z*Cq2eB8O8P1Rj%s zV%e!)TlQ2g*^H&X)ZwSnb_g!--ja=yeTuYG!&ahDdL|m3mXhp5fdm$;sFt%9(c9Xus~1;IoDlLEJh4!)7EMr zZqc0KcrHRPFj0*k@wX2!bocB=x@g_iSWELDu)lwv3X4m)sNQb5CnqeN;zWT$a--&@ zRpTShxnnR>zut7ZKh`hIw&mjCcdQg?iQ!9I&a{b&vMtBH5Hns-gLP`oWNLqFpWOBt z{q>=Jt)inLu`>YYnQGTPX<>RrK=Wj4TZUg%I|_Py_X{)Ou%?JnRCT=?%Rsz!_~jH= z^=Ev#)-#^{wnzDX7zwd_yXlE{r#+VrDUM>MQrkBJoPv`T-YpZlgm;qNPdoeF^B0A? z2L=(2j7R!*IxLUYi3SwyptTnOt7;JDwV5B+T#nA3cVe4JU9Q;tmTYF3A6m~abioMV z-HF#MtS$?EZ$XdB+n04_(!33)xKBjGxEYW3EUc-TeUKFLnM899MxE8VrO1^9f9~WY z>udgNb>Hf$3py# zIyF*XcHlj?vzy`Y;4%NHKVTIw?{SI>=F{^~@#i^AM!_;HCVvJhax)`F48p6H`b7Y{ zj41^9^n&M@$|}bWr7gHI2OE8of6^$;qtf89#6SH zjf^L29$OVbWP7D}9tT5V*IsiaRYw(9dOM`-F*~NunfMoOe{Y>K8(WhTkGrmp0k8O! z`6&P9n|WAYQCq(0wyzxpiKMqu!d()#;8xhtSuPDA;D@6M0~);)Bv>(=+_1NAB%I!S zaaW=$y!SxIh2?t5HUrr4)fG*J@HPj&WRZg8NQRU1u37rtcY3_8lTPX~1XUE*Fb(O2 zx}>iA`cZs(H5iOf%N@m?^{?Z_xxcXwaG>$>u)RPpi;nf;p)2{ z6^xhR(MXsQer2)wl;3LWc5mSh6DncE1({XvHK>dA7)lp?AOTL)>B- zN^kyej`ZknUzLAU1$fF)3I}7wHZQ3nVV&?da_DOTr zzk2IU9dt>!MJNP9zYf)>A5eyV^Y6!xtb;Sc&?3ZgL1x0txq~$fWaKtFMxpiNn+Gec z-}Y~o?4~aqlt}@AOuU=2v-yNC-X@is(3Gveg&h%Qfb!}l@Ih((yQCozs+qEYyqXj2 z)B1a8AhtPv^K|v(`b9IFshfs&n{a80*ze$tnM3Iw!OM_R9O7jSw)8)z9G%m(ZIng@ z#ku3oYEsHRK=4fd3Q%sYxPZ6DEMr)%ugp>K_-Iy9$}3x>TTxk ze77n@4@FmduMO{G8WkX z+Dfb}?^D3=tOkbY^2k;ip0hJ;^d;ONdB7`jv_JkLLer0Z4ef3SCw2(-Ju_*<@#?8yxH{Ki zR`j1?tZClG&;w$?XVMR2WharaC{~&jh+YbfUp#>5^ERbcNSLv1JD4ZQT`s?w;c%O?iLC z(P%4iI&=Pt6OrXMd(#BU1t@8=)LTY0oY^%*rB7N;FO0R%ix%kkkV7&zLlC#8_ZJ7I z*mD@F{hyINlMuY7A+4{QKyv?7Vx!Lmc-Yspf^Xpt_U7-*OC7W*?s=k5JM^*p2>`RX zvjJ>7jFW83A*o$fx$%oHuPW0^;a*(m>+Cng%I|W zPZ@u%smIXyTZ?|nBa?}S z9xotyW3jlTRl=m`vAe8xA46k}`QoI#6|`**MxZW~XUK2k=;GfvT6Mmd@$?QNbi}fk zVYp8_qSkdB{#3y>Qp4N*2uI@h_`T-K5SyPs-jVdBikMu<7G2-v;DQsKm@OsMh-^C} zb$2t97a{n8*U)~>mxG}*mv?qSRfv91f)?s%=?C;$CUIfB~$lrI;7= zNs;jAk9f51%szR?#BVf}=}+>0$A6IjbC8SY=EHvtO1tVJ+it`+BkUtSS@3KB&nf)P z_FB%BCMzwzFk;xSA=9_od5{P$bb1=E=yQ}O%8n(3^<8@PRNpdrE#T)oR<=E4pH04| zua|AY-*DduUx|z=89-q0CB`{t8X7j{QNHF*-l*x|dXIY&mcc zSz^mo&F5vF2COrJQ4j@k4Ux?k0V9NhmQECWW}+s7bXGMM|D=w=oIV&C;wG|j4RCVe zl&--YY-qX`81jP}ys?mjY_Q4$yR1GwZ z{{A_7nHIDAVUoH{Ss}&l?&u1FOdp@UJ`CKRCxQE~-qvLD@&bU%8xTrf_4b00N-NZ( z%p8<;XVw0`Zsp2$wGiBwf;ow2$=olqZiqf-3)fPHef=;zJxVRhG1;h? zYuY|{^2tXlO&%P}mDSY5U2O!2o=;}lr-sxRdu|WwQaa<=isY5~ ztB1Sw)Q^Pdhn0kdqY1Vryg>}>rU^*h+X_p8+dOFw|6EWJs6reRz2#3w%6=LONHHY# z#6?i5F!AXhfkeT;%)YZAZpjwAoZImt z-r46jumaOU7hXbaAX#U8k@+=6(Rq+G+k__5)Ot^4fC=I|{e#c!2YJ9g>l?)VC;;$C z79Aw@9nJ1u4)WW#z5TX3{BPz*4VI1CZGYCTsg)a&^dE@|PnoGm-@56sjn8;p7#VR` zyG_$lG02ZxW?x_O5Gt09XF-%F!&8^v*4^Y&m}GxY8hd+^%4u^R+;dmK?g*I^ip|xw zPp6b~dD@KWgQ1c0j*GD}^K)9sZ2x>JsZWgrO2{YZ%3c&z&X}^Y_TK0;N3+{SH~)o( z040UX9qU9zMb=SZkC`||Q)q5VcXjylR5P3&(t)>fTXe(N9g-5ks^lY=RJ)Cg&C31*fp(zWKbrR zuc{4WIPHtT<1dt7o=0t9Z--+_BnRr?dNmjPn?%s~SM|l9TN~@#v=GpD{+q3Xy}Dx= zoF?5zEhORrcoA8u7G}=C_kHnNN5nzM0hjREX|+KH_%oTf_$lGd#8q+N_u^2BrQfOk z>(H{_)RE&GU_)u7t-_5PYEN=IMg>Xb?KqCcQNyS@&m}e)s9j4Qtz;RG+X^=T9C;<9 zRmMvja02?R3jn=(Xmk2!kW2zILWGUDJ2M`PC1K^`SCw1KpOg54%A_ozAZ+qxwA|&R z!(T`}XI3mt$=P>k~*|Km-LYRBM?J}f2POj z7pdT^3b|c7P`ox%ysTKnD;3K>W9zTx2s7s&(TO)M4lNIdfO!zKnpLg_L2AE46O1{? zkLT)hO4}?#e{zPS&?%Bge#>QUFtuf!X*hIN2q1gs;HLNY7;EAiyPME^fcVZU8|FzxMtXR=B%vi*0$~bgk>Crw?)f$!U$X%#(KjuyOFa4Hw^rM< z95fs&zh$|jDPf5IS@Hl<4T(boVm4M-pw*`{RunzycW)%@rHCYJkkmk+06T)x8sIpS zgaTmXXW#<|01FZUsogMUtBo_9;N0^6B?8>@a~xDIvY<>d4Cr){oKXzBw_3|3~nr_Jw&T3)@Y$$h1U&)IqlNnvkEW=> zdtTg&y{21a@aw69-Pw!P*gRD~Zgpxdq z-62;`j=4|yNP*S{;iQsc{k-yhik%FLw0<9LO~q!t73=whS(ppRe-#Ah_KyM_rBO3y z{-C{kRb8<1YNM-4MVvgXrl85;PK8OL!^d88q7D}E-ihZ87X9&zZ^J((!7(r=N+clh zlD%>d07!4s>0$S>WDK*!gd5}^I8fTo9gr^|g^)b4NLV_o@y0|HPk6x6P?Ta%(O?h~#rx}uEm57?jLKxqn-hkBe9L-Djq+sNn2&>7!MBc?ZtjaQTLQO#?5EsO{hlDcfS*vxA|4G%A=^wf7-GY<7o} zLxuI?&W{A3bx8h@RGAFYW&1GWqJt<1Ursf1mgC#K&CJT0y?2vETz(NGx_?hfML*0r zVKKHQapx|_*OZSGYHt&6{s-IPwY%jXtF5N^+$c)S@l#X7~rF;q(vut+)J{VeW2|Q;zAYSJy%49hYPxC!@Bayj9pC zGR;iE9KxJenugBc^didvs}WREz6CHKa3@NGdb+?H1y4TUbbkm`2{PeZPG=MMFzkOx z7j2c^*cjCVC7cwRGTKjbAb#H(Y(9-#>Gjz0XW8REGWZ%!9l1@Ri!bf;di!^WZX9!B zUJ_1s*$05o_09z&ay<}ElABo6eo`dyK_;^5^K*CtUx-M^O}^jm*mdfo?Pmh2QKlYu zwwZ<8WD5l$+gyEjkOa_|s?zX&T3q286d#8j#^;qdJ}e@MXQy6?O3ExgTCfhSm!dzP zXYG=F20`DoM>&-o=EWn8n%EgV(*8jpUYv`9>rfkYv>; z?Wtvl8C8mNAsj%ZML?ORZH0r#Xi zoMNm~f3e@bBu&F%{9f@=Y$!52jnhi-#Z$z%cTK@=iflDv7j-c@k>d}6xZwD^DX(;_ zkNqu2u_~qmsbu#ddzG8B;VI0Yc{6C`{1hN5t3Mi1_y7<2&dBWUN#_A|@Ged?_*M30 zfKoPC90jF{LTLjW>D=`HJX8kveIrHkeN~^B)Kq9{Gy<5W@7Mb?GcR{{!~d+KDEs$Q zN4DNNesxZraxz{?12O3LO4BUn4q&{JDBLanxrX~fk|0Sgd3cEL&^T_Ln+tMQRcbR* z`^RYJL@ntwS0TyaD?y&H`k|o*KV;;u^rvx*gtd#IL!A03PdY5yU@y#9B&Ln5uH;Fi zNfp8?^>Afx{TKK#$%6O6!oP;%`Z^)J*?3pNB>8Qe zHve`8j);DmM;8zEQO7fDb&10Sqz%B(qr%au{c)aAlL2Obz#k`qsduLY6enIe$7nxVSS1(m+bWfo-BNf{oUQ6^+)LGO3O{pG!m&II;iYg z*l*b{@MAA~qu1!iqV2AT={LGGSI*_@k9b8rV^90%tBxwK1o$xaa{TAGBzs(fg!|QX z#aRRyv#z=dLjq+~v{neOsM1uUrVRJE1siaRjPvP~=#K3(gi|?gHVUh8pBW+e_5j7F z1CQcx(qaKNgw73fGt1EiUn+G5_jGVH^(Jg1GrYY$FPpU=4Jdl} zkTR9Gvb%q_gJyJ3&3Nf1qz#P0#3_^DX+uh zC>V1NQS^j)WUL3sR+s`DdF1F5{~{B6y*9U^y;_Sum6K{QrAQGSGD)DK;ZdK zr*@RmsG>NB<8Ss?lh_fEc$VmF5MENHy-U*0AN&jT>_pDmGNj#-KVl81R7`N%s|atT zRN>0a{5pNu_I+Y6H&idwq+wzZ@pGp#_%}iwjJJvStYYdO|K1W!L3e0gR@44G9I+l+ zC?hzR?5f857TbW(&9_XS>do@;&M69=(#T&|*^MYw%3p@psp7=(u)emhGoA#?$Nc zo<^=qPA3^tHFMkETw!7HM~O7^9G0*@RSJcVC}^I)&(k<)OzXla{iWEs1dbw-%50_r zX~5k$&vCoiXy$e^n4q9%I90lUxll+l=QXZu|8eFo&cb~`JL&@uZPL-Cw;Mm~4 z%y2HUlOZRL@OXBh~Hv&)L%2l9F=_)T{2yR|Q*gk^N zPVBJWG;+dlKsp7UhmzcIpG7PaNFKob`oC+017GAHaqp1Qf!aickN#`_v-Z~I;~U}1P7Sblh*q9= zPjOD9XPDWw#q+@Ok_v9I4m?q%oiZ+5x8czyliB2&#}2VxuAj`!f3kicMQ>?Db`n) ze`u5idigrtoY?5)gefmt>-EGH63I)1icdkhL{nl`tepPk$ve}6`%|aKVX;GY`Kb1n zKi`JptLHTfc(owYFxHvmhN3U@r z%5y#HUs|J{qpK`|sYWD;YOri#27i?DT7tug?33aq9)J4OTq&A=sUjGgC2=^^IeYU3 zXi1+q-|_9UW;lRy;{I8UoS%>U2B8t(c-V&ReH67P;^4UXRFzNXG1t8BQ5MiASM0FT zb6Ha0=u474{EOS zl~mAHuAQ9_oI!Ee=Bp{4rsQ{&8AqCDDt0%K*W*Y2{u#bKL`av1rMw-}Tqc@elUc|E zZ!XiH5S80zu@$=n6}*j<-4guVo9WtGpM|`nwH@-!*_nQ7gpe?Sy}BZLh&TbdK=o15 z-TZC^^opXG$8OKo%dp#>eP*=_UOCSPWh^ZX2ImzV9IuZe;`XDV`9JQSU^L9^v<{#a zVF{j3&OQF0+sQsCSw9G$qY#j0>oC99OKEJ9sv7nRDNQ09dV;!nYSHXYyUBgR??qeG z;QO7^L9&V;C0n|EQJ^egB)WJ0pZS8h2eW^Cg%a7OinLT++W(RF-%Gp6Nm4`a?R(~E zI2YnYX&^;n3TgCm^OU-h>FCEzvq4{!;IXsy;ATCqjb3}nmmiFq<}M~R!~9@3rK$0N zegH>6w9VNNHKn&qBiYj12pqNq59csUTpu4_);2*I#n8&sLaN7hYG&r{T!v1GWX70r z1h9hdZIwNUFdXE6hxRDt%O-en?Og~qB|0h?HjGi~UEO1RGOyIExrt#o@zq?4<>%#m z=3u@L8k53-6b35q$x`Q24(`%pbm@`~P&$U=!=>bPibft6Mf8fltGPieakF zgoW)QPI-#*w z9(M+&{)3`*7+!^)#Wbu$6+AzwU2VMxsSm1-P^tDDbo3%Jz<4Do+ZK~IW>%GEP&(DV zev^ZJQXK_%`}=M9YXL{I-5R5BVP!=g(-zQ7yr_2{AyF~8a%9L#!mu*QEzTZ_8If#8HwMj8WAU&j=!5K(|=~??C z5_O&HYyZy$GYAk=J{kD|{Y7QDEq@a4g4-?;4csd9R{*xM+3K@J>c# z7*Fc%=)cS;QRngFNE2wyMg_d9K%J2A|E*SVLC)>RA4XK<=G* z>**Gi^`yuOrhrmUDM}?RVEsHV(~)yCV)=@9_{zc}yGR}0Och4tj_FBYj@1q#RitCf z+^74}$7Y6Zn|ROauIPQJ{QIAQ2=Y3`6s(l^BaYrpXLgzbB;Hb}_x0ZW!YM8jBAV$j@&mx?>wl zwRa}9Q`&UJ#2Ao%>oe$XArt9rNaY zTtV~@?;)P8c5<8DsmlVLw!pwy0AXji33E?{KFir&eeL%Feu0hH9j(sN<9T$y&ELp# z0WrgYQQ4FZMNZj9q>p#Hoq>@s_5>Uc%}g0R7IbnCkK-dYI^@W8C=Pqw!?UyFo6uoiW1h?J*kM_es^Ew-gT6j-Nc`YIs~U1dA0P6VR3BPw ztbVM?tn3ie^svVb?Y$>?Su=W<+SsyNL%mf`-H<@P0{JIXYZNxO&$rXRe1{x+f~lG8 zKJFeEeNZsE?sll`C@Bg1kjI&Q>3exR-SGGAcu^_5V!i*8{NrBHRlxL#qe0QF>rHzh z!o?wD%R>UgCVUIR{;3NFp-zleld$22{w|Y-TqkYe0T*C0s)|1adD;p4?m|rsue82G z^Pvj;=i8vB78nYkbZJ3qk5k-WTfMC(*Ay`mc#z}YQE2Ju>$9Q*L5Z?aAnHuC)t_b> z+}2oiigSCkaNAq3o6CvsHw;rdc?~Iz@5iUHV(ECtA8}55-qkKi`W}n9 zL}-Ci2zFp9E`DWrUGc_PZKOPxmM{qy)w`r!wX@*Ej<>79*hguI_vfeT4X(tam+~i# zmuZGtHWosvoTTrQYKMj^1~mmsz0iGLKqhNTy?Ye4vApjy9)*)WSGuN534K zv)>v7Zdp)c^)>clTv%GMb*QOXChhlQP+<%gs$D~6CD@&zKA0oVu%`11CQvB20HGsi zltX<;52;i@590hbU43KsAWZ!~L=qpu<)rETN`fS*->BvFfn`CgX2dhawBmV{hGv8r zvcE8C{wCe_DV$ zBn_czVVDs<#G|PC;hyf3`R2%9x&i11DRDSo-;r#43*cZseS{JpLZiik%{S*pxMq)+ zu>li!KQ-|k(K3)o zY}VErueGV~Ot!$Z;B#tNAn{>k8N#V^{q+_bb8{C zE4=Sxye4eRTG~{I2qxaH@hEiioVzR5g6GULl_2#Dqe1TI2W^pR30~1Xhx7d*P`ODo zwU`TTsGfBmhT8TeL0!U)?09HZ~j8l}kInxHDmsX)eeebOQ} z@_M-!P2+~m$7tj=c&GbkIVSM-%NEx2A9TE9o;GW3go}Y@GB;sA;gJ2tEeZ*V{1(Pr zX+`tE7@v_}N?4f;hCzfwScK#rzY+sK5)3@yJsNzFkv2j|FQV94Ry;Yi2CcD**zqHu z|2}g!-%JWw#QSv>r^KUa-6nr^X=gG>Wj~isr+q$cL8m5@JUNYibABGxAXdV8ptg=j z&d=dvzYS3;pZ_i33eB-^dqn`gNOcrGFa5|A#KS$2=7`K!^w%=DN0sFpTBT?#SEiok zfh>fb*Nw*(clZk-Ry!jR1oTOC_sK)_(}HZ**$KT=2oA11g@Edx_QgwvptKE%4`R%)=?Bf=Rj8MWM|8fIoxA1Q(EKH%yr>FfZbV{am7Axc0 zAC=*4TW^0B?3I*i*-gZrP0VXJ_XMBldZ~f$e>WFVfFx*W?YDe}S%=*+hMAA_ooDJL zy;1%jdSo1FosaDx`7VARAU*|Nlr6fs4nRN?!Ja^U z_`pZ!{+=oe+)&wmp|AXVO@R-Yyqecw3`)EyH0wn0kF^=67$fWql0iSPFE}9FP`^Ni zsivC*jTt1agnoR&V7U?ez{p=Fnpn>>EeJM4_2`n9!!V!v-|Z8By_{*NU~qG2c7nMr z)^t*>v<1OBDaPM7TBRXF_!@-BD>+)-r(A*iBsk>PRw{yN16w1=~W9%2wc`vlrD;;# zDJ^{~v5s!nn}-kHEaH?3y_n~d_;maEolqtHA%B{1mtUJ{q#(j+$Jy|dq z3SSzv>vUPqpMibAN@cg+<6qbhUwaz?&Il?V-Ks=KRi>zeq(@2PUd#}K#PVCzhADh zthU837@7C+MgtqKAhzq)V{kn4`cynrlx+(-SBu!81{`s?jcdhOnj5{qqKUw)SJn zOGDIQX#NmaI;O{8+=d~ZP24(>@qy2%cSMzP>Wd zPXxMRj2UPaqn2|Sd&~JrtlLrT3a>wjYV;dXxumPbhYz?f3zPyJh;y};)u+@PACGzw zym~umcc^#XZW_YIG!nGH1zVIZg?7BzMB9@aYLOqKJ6YI+_TKe3r&jDM`@~Piqi4}o8may%j)j7dUfYN-#+8U%*X$LjVLIs zHZnzuAm&E$nQ<*D>h`0%pt&=6wpzbD=qA5d-?#bJKY(}pQBpwwv{3ZWF*l`^`Osq!7Oq983ok!;qyYuqfq$n(bOvX;6++Y+xV*zXV5w+i|a9a1{T5qg7jY%Wu0LFTI6` zK{}@>JRXHjSym!O*TN8+sUx3l93_ol7?x? z?SuY%PT6te(7|ZQ4)U7bh8Xqbtozgfdx@oG@Fz1@oaTdT5i1cQdy1~lex(~=eLxLI z-rZ&K)r*xZ$mY{Tc3ct27G(eLt(V6~Q+A&&`DC|7q$+GIW2bQ$m0?CY?JPPaOl=i+ z%{jl1^2LGUkRM2Iutn{^-FQC34`toR*45IC{SqZ#@g{l6XM}*6@lysDy{DY_&q)DQ zAymlJ)?<+xGoF{)kpZkCbHSdT&BjS}#XdKKiGl{A7hy?CTM71BnWKvwz_VA}d-g#I z+LIDIVKI#pLdfURvips}`LCFJE^#$(er!Fp%hlIlMQ&mL4>-=0cN&6*B4dZG+ZY3T*U1diTcj-ibq3FZ(Hdt%|?VDIWe96~; ztBml-$_{U9D*c7y%>~eN0x{~#f;p2_SVS?oUzhyBOqDsSFOk?DqPja{?F$&ewEa7> zSL2Mza@9X|^hS`$I9Qf&f@Y>+f2O1V`}TX?MJ^7c{?B1T$M3^NmlP}sGOcXMn?vtl=@6WgR%})Yt%Cd2u?K}%=n;Oeu|l^bF*UeEpqBEL`$_(IBwcDn zIL=ftdOh>lJ#Iv=249U=i18xote#=ct#od!Z{)7r_K>XH);nFFpU~`|@4ji!H_E?@ z`CDd+)b@ z`S9qGv-h5vHEY(MX&j8%Rz%NeZsC2!j@r|IeVU2Dck@;O)6$B+l71!3s4Wmv_-giO zLsId5kD-S@T*p()ZZHN-`>sJ8Wk6+j6uod(z(sp96{U#k6u$hwZojRdB+!XpB6F^w z1RwH@L-{{1C?z%)OHySYUKE&hXB<|v-K3Ye=9|EkMU<55X%e40)D)E_I)<{!9r_F`b&nXUkvmYE`t-DcP4ZmMUTNkkR0 zz~7g~o6?WD8P>qN-*wgVwZ2?i6Jz@EGr!=h=Np;d5#Nzrw$4tDa!`rpBTnLWt|RpE z2aV`lFUZYk!z;9PRagWn^gT^k<6okLaL9v#|A6Je-m<#ULIJ&viTrLp_a1@^%;MpYKrA;aihJ{B>c|yp`#NzLr0LD(q0B|DZ(Qt}>~{SJWr@mG0xEiU5gu?RUXGC-2V#=n7@;`GvSP*_DDFs-Dq^ZsAWr!7vs|0n5u1?7uauu?fm$Ur+A$bhoNwV zMVHSPkp?qs&5xu)X$FPuSc%A!gzMQQ@;=-FKO++~OMb2I8npN-CE$m2UEMs$xs7?k z^>u#magw8Vp!cPwt^%)t zVV|mv7-kcN?c+{HWTfg~WmmctL%;I4(5UAxosBmd?XB1?YM>Ug5&wQBBa9y$;8l}e9a zfisZt5`>ee2{;(DTF52eYNSFB)cGQihgIm1wLHG?ZO#VUF>T+~)IbMMG5d4aFG`+Y zt`mvJo*jfzI+NN4WFmE5zT=B~o$;*b;7=9b2V;Vo_8;n0X1iNV8^oO}b(%0M#_71a z1=l_~whSXtKp`TM^DZ8bH>~Z9?4L>&CMH6)vp9+T=NOuVE;PSk_h)&^e?-*o)DI0V zmEaos-SH^%%(p6Fi5(Ok=d|j&bf1BEpnw99fMrrHWe+0NGsd8FRYtym&KsAK=sYdu zvEP{Ye9N}*X}m%fIY^`9F_uP^EiX&`?_i?=3XpDR9dTb@9>fzX;J#)wd4YtZB~*po z<+&?kS{Xp3|3|Wzq*HY+%=VxI0DiK+CSqpfK-ZoW4cBM159{e&s9+PHh@WGU{x_#$ zN1GTt4k25d4$~W#9VD&7vsGT0vous!Z(^i977?-yAr# zSadMT*+5^pw7lve+{b86VIHNXp|!PTIsgZGuNz@&WwClBhfhf-bZ>|y7euT%{F4vG z)p^kEZhTf4$F`b_zAeV*@1reOK#5DBhuSPsBo?t|$m?z5s^$gGvTg<}0se#pq12rw zc7e&LDt-J^oQ4WZMcV0+!T6$5n|`OSjr-O*YK2G(3Cl+W8R?HqPNJtuSf5o5L>xm`7zbo74R8(rvS_8yhac<@q)#?Wz$%)=SKo3Na&Is zd5^dH+OU^5zhno_n&W>~tfIbRU_@V6Fdg;^N5tpuShEt6vHmeq*Q@oxK#v8SBIfXi z&M!keOO6TX6s$KH^31h6pGQQKceU3gxI(&9kzY^ld<=p&-k6FlVve?-_y^w6C~8c{ z=USjIq(LByiYRH!BPez&qepv^2|N&p^Rtr^B&Jo@e5o%%unjKaC}>yc5&BN+I{h_* z+N{ma$jfh14n)&QNVJ;3BR8C4d6Jw^;a&9OS{f#YP0sER{!7553t`)Vr6o(5wjg1CwP+!WrS!Z=gGN#{RFM2SZ}L`(PN>%)_hG`Tg~|< zjrX}GocDV7R?5<#$V9*0T$M3469LCayz29p3 z#jqq|%%tnqqGD;r=O_gvL@7;V z+wVt?`eW|mm)Qh;l+j*r1#sORu#+&6)|vVmt)1@A;I8KC9vmhqz_;p;{3yA}L4CXV zp5c%36bb}B5EB0{aF3)lN4WTk(ICGQ`Ni<5;{2{a9HaFk3s0$E&DLo@n3Q+QmWh* zXPQ;w>uZrA&f|HD6?7#+Ev@e^u3FPr-ndu-CF$d8N{IKN= z&8`F`BvoN6#aHx|%d_h7I6?2JFbK&wiZpOkTyUvxQ8G^w^zdv;ZTY8DEyMI#O*L{F zN=w}4H1JBF#3Nw~L7;G%`Id;!OS*y4xsoMF+t>EmOYyCfqNN zb1eKE@Ya#qYEz$KB$m;X!1sukA`C1j?x;U6;J(X@e?DhfQ8V^} zDVjx8;V|39Xh)V@*4ccqs_zg*+9}Z4M@;)pNRT}?Ene}WsjlGJG2?R~6IMc@xy87< zKW`E?dYU?(L-bGbNRU*h7AjQh@+NG$Y4_G}6bn>t{B{0LGNRIAytb$4LT$h~`!SvriXClhz zHm(~L3YOWDz6gD<>zmaV^!o9;v3bl)L(f{@9L`r7X?ke|5TxF-VJwFF$jaaa1f0T} z^M3Vu5eH%-+5IZkz5O}n;)ACEtY<&WZx6Jclg}FGf7+L8+OG;bV$pYJF}U8iAhW-6 zEFvHr{UuAxkh-$6^Z87%-pjWaSM$;v4p<`LZTVmfX%-omm(SR7%+qO!e`_E4Q)y|9 z%f33VNfLdiI=xF3L_G*QG-^FKvcFIvf_)VoLiYNfwAr*wUE&emI2B}3`9>OSh$jVm zFaJF`i73l0Aw64rZ6?~4S2i`bnR`zb{O~Hg(bu(4ZT^O*WDpd2D+)?kDaKj)ft6sW z2e*k{W`?CCvRY&FY=%kxJR(-mQxGmqYu%=+kI{<4PfkW4RoExusDqo)N2*8RJpoD% z>^w{?2d@hHJl(rAb3HW?9r;?RZPPcF>J-r*SE}#cl*M=ie^iT52_hTn3&;#bEKT%H z){aO-6JdsWbrl?ocm@}uXU*MvH#ubw{FhaI)mu}!7R^nq1ZQ+dcKX2!QG!~^R-&{1 zMcm9cYoZC38$lyd3EQGm5@F!+7N*Kg$(Z2Le$bEE<-K&{{bIf3G&0x5H>s>7pHdJZ z8bY85xET;i@62Dzh2kaE*s(`YNYike%y%<+!huw4g1;`(hB4drs2AOkFZA={?ECY;R$Qy)mC*g1#kk{EKMZtkkM$p2A7@UP6~~JIRODC(!Xz0$1ULcx^I6tO4ykW45Xs%2rgrFxb$=>*=ii&Ur^kan3NrM1 zh*D0#%+yysZF~1wm+Xy#|6v=vKX6dg_7JD72nwN5e8@% zp7ow6GZo0#Z($tM!TyF!&Hg-j7!tzZpV5DgEOIbvk3g)`RLn_>6JXVaT%+Rf#%TyJ z!L_&N!OoO3hnu}W=fTYU$m-o(Qu;{yduWxQHaFlPE#l7qYkeB7q~`iXed|L*dl@O) z@xJszZs3_fcjc{_Y-fDti8gaBdN)713hdhytU_53u%1;g?@tzRc4tK1(*-U4!zruC zTSWJNgxD$|MNtWB_=^PL%OgPxzF^6|Ey;UZj8$0HQBk`L@MZCd-pD)1#itQ507{o1 z4`h&n@^ieinzu)0&gCOs%9=B~P-ajBK(mPcXUsPOFAT56H10o5<|$Ed7)+VNQ% zZ2>>qK$O|SUBhLz7v_otiQLUG%C?zjf!mn?HB9@DhChi}C%5AGTHR+z{lSn|9Fcsk z@ZU=jGg&{Yup)gZCd;R&r27RfyC0*(_3mL^=UT-Pr@;OV-$zY4fd1|imQ~<@zGVB-p(`l_ko|Htc}kUuyQPP=+Z|-CHj@8>;Q7i!<(>J&C0h1h&1*Qvy zOn14)w*;8u@)#-*0r|d9HsWa4%#(zXScFguh=lLJDrDHmKkc{a|Are@RzzxI<#iI; z@NMFi??MEZ{a7^&Bt_Cu9VgW5K)T**EoUah^fGg(XfxIedG%bW-ZU_WZS$}86H|pT zl^&u>bQiDW^3U{XyH9ix36CsL?B~BnAVnrc>qSn8TF&mQ+4~8Ljv;op!Ahr-MiKpZ zH+z+tkwbk`=}Es_tGWW8W><=pb{7FWd!vjdmaCw%)^XEvKD}{QAO7`B*YhItls1&0 zi4WxDBCXai`th?laz8RYMn#ktbx7OA7%RAYcOsY~)v2dU86{-wW}n>l@RfD%DK)>7 zc4A9dZ!o9)<-vE7m_Z2%`q6>%qt#67y2Zm<<9nj9#HquWdyuyBloy;cxUR|TWsnc5 zTBzz%fiUHD-~uGef&%^#f^#u&JKe&%o429=?{(ziXFiQ!h#NvgI7r{qW2IU=5r}$! zd<_7rEzW|1^d}p|7dTM3-4BJ^Y7#o@5*#>!PmGf`CW@N6zvC;a?SPO&wDN8I=xbj? zCGqljeS^od==+MRy%+SZ`e^!%EiY~PHkJUR6We~ckJ8ARs`dcSJ;5IP#(jgD%QUMc zzGpUu1odT{8JOA4)nzGz1j%I?!YZ6I8?l+^s=_Lq+bNCgh(R#{fcOp5X9y)jEIir!UN@$tOU`F%%n7%@@%Y&1c-ch{|A<&d`g4SL+ z!^g2OtUSl?{W~_j(5lRfDDFYro~0>+7fR~MuhXRVXB8SX?jW&s*g(nzN*}+zpDnsK zWES7zsas}*J+?8ar?wv+3Y}(PKk(+2{FQ;AXP^OQ2)dHT)Fsu`io5sP{t{~eRs(Flo}-`* zdC&`}qoSb-ruC0f6D6sZw*s!F`1ZMP-mGzyTK>?mkL+8@FF6&_Z?x2NeiM* zVJz~t9X!8-g#^)bSeT^pPLUG+)KbjCZvEhLG|JMd{iLba?@{?!S+BnKlRbJY%u7el zxy-)Xk1xpm#w6IS9~`&k@EbbFYQM2Jf^0Qs*4OeBBzH%Nz9BIY#ia1uE#m8I>6&)$ z)3uV-SMpguAMU7$?`m+ClF;?|B%rdW)AFD5oJad36e7MY!Mlg;W3PSnI8Ga<5K!Ds zSMY}-CXGQo_?U6v=T|L_)r^v#QGMwWY%*F(1{yvO9z{_xR-j9E@HxjLEUO<9W zNGPkUW}K7_FwSgsjItR9y{4hE_#U8l`c+HmMlfd`25vkOt5bLq#&p*dIC|AtQ^?L| zG)%b?Uld3Vv`{O7!)c^8#Rh(+q<&cq39$Jm3x%SFyzi~m0WX3i3*5RR5$*fX0m+m> z8z2=GX^mJ!BLk$K#Rm5La4M7=s0w+6l)r$3QHe{KQ{*%aC!dEOT}p!W`i)lEJ{m#s zbYq%>Y(v<(^qtSU(+~xst(Orump)Q)rKfQK!WdSGc{QNG+MnF)z}EXC;^c8xSzx@b zBDK5UW1CQZUPl)J@G6d$`*)NLv$)5+MT#yb&mYACctAT=Ci*}oX zSnt5o)tZR|o28XPn_Y3drq{%z83{1Mk*cpmNWCrps|5g#CojE{U_&kwr(=ZGX*Z5) zF0jqcy&b#J+J!8@U5t;!MRnC(K78_L{Rnf9K^m)sjOV<>=dGByyYJ1!0Y)10To0Ze zJq>j2jr%t@6IDNV1$kn0WJPyWKgM8{up_^CtOLwR?mvnd98R9-{%iS&NIGehAJYI_LJa_z01mUh z8ULG7dUNqftM6XGb#alhUJ9cb9snNqk9}oQTlYZS4;@e-qG4%`p_wN|zYb~aK-1@RUp z`L#S#LwE5PhiA(jo06)hCA^bS@>VB&8Vw271d6DgafZWU0>~Ui=>tpUP$K`iQxtrf zc>)Vs25bG~`$3M=+p%wQXD1=&gX>M7?k{}iyf1!zYcTcc6iANPzw1H%t&uVnNI$Wm zp2ou*zJ5_T=&Bz-zA&J>v@}gL1HXhZcRzC3tE}O1K%C2Lb?MKm{@t&W@i;g?Yr2W zD97>5dfXYoChU;Mv7ChX%W42>hU%}rs%EG#4Ab{en44fQoE^TT%;T+m1|nU(`&|XS z)7ja8K$4MA=6rLt0O>1-G-A-z!Y>-Rx zfji0y-#ao^{p1)uquB(ikjK?H97)*wvZk6x+48K@u&jvGnuz8nTP|MGx(6m)LZS5X z^pxP*Z#rKWs5>UPzT~0OrQ$SImUe{EAh0sCxSPoYqYzRct1JEH*z9Wuslw>Nk&w$& zE07#5u~P*{B}t2Wh8X`YhBr0V0gPM;)%{6)9zS9jy1P`!gtdh&q?ttY4Kh zkdEs2Qor<^i5fodj5hJ(C$3nC7Op_p)?82|H?~7O%KDD4y=no$&uh$su z7*|ijLPm@}gp_gxknIv6oNQU?j!$f$G%j~LMakP?00O0Oz>>Mg5$7v5-qSE}rm-wZ z0GE4Rd%KXi(zcHN+DUhdY-T!wvp;IJCy}0p=qoGIZ{!Rk%q&) zhAchI?j3k*bS)rZpm)^JeizhnWAO#KzfczD{vwX5!R$Qo!@fvGDdfp_q$QO2QV5`? zgg|SCwz~;2!MR0JU!1om6lrF=nI%4#IS%ticiOynwX&Cb$U5&1_E8JNj&rnxbr+Y9 zQC8bnOuXI@eoVLglGhPu5yq;H)1acV`ZMVbKJud4ySpLHo>VIFoAV<>!J1l>Wo+an z+_OHNy9LK+OV2!WYRY%&mr&uBn1%?-YO^Rj%M>64zk`rZqwzrYek2q6>p%a2nb-%( z|BQykCb1b&=dfb?zhLv~^!xd%#NVIZQI9o~wfwbh>w6>9$+;P@czERvBjJ?CfQcza zApoIvi27Kxo$+6oMMeEO6&o7NLpCyIHdm##acW;0{2!kpBxh0ZCV=Km zMUY8?=WHw@n?o{p*r3x)OYI9JpkL2Z&wF!I9vFyuMyga_q?+oOS`%0!GNmbBG8huriIvw~Q&ghlnS7|4^6Ky)4*? zBC!43q|}Cy{iJmcPgLHxGQjx2%G<4S)``BaB|YJrJci<8U8rJHuevQDM0CXoN?fX{ zkjT!W<3@;ou;7RNPeG{y0hd9QRmctgo?emw=gVLanzpIobQej!BczgA;Gv*izOf@GwF-Dtr zCa8Wt!bz)Ya1c%t^h30jHRfh^zm(@C45^kn*KYl|@TRnjl<8yCf$Ou9m__S@$d;B| zEw`~~)r)6?6VMyl_8dnpAK|kHC?#HriTw40*~}LxXc0j;$tfrODdbcip&#g>LE$LG z$;X;{eiYWI&B=qZ;=+`I6&zF$xt$Z_^wehL~Se{h8rb-Y_742-lx&r+fI5nt{v&`|h z|EXDU_bODVfE&^yGQCo>9$zesmZxs9a7C7A{^&mFO?h@*;!D@j>6=HOjb_}FuSfG- z<3SjL_ZHkontGrAN;B~(U2}kx1R~<`pGq-~#^CW>237D{wZcv$LYQFV;baOD)JxLJ z9U`fRLIrUu`IHVxi%!xN%t6*fSZ?8rDlKXT)&&JLoyEx1I{W381u3$$H)7xEouv;6 zB9OvE$|&2ri5dw!Ls+|5v(O<8?lQt8YMIYcN_F4)-~60Bb?H3ex&b{NlGh-bxzFiz z6h2X9z1`TU0|U-0wyIDfJn-r~y^WdJ=WHZ^g`B$?Snr+YiKR*VEy7;pYSw*GWa?uH zJnVq58YJPL1}HQGN_54~B(VGXcm|bZLT5KwAGMg6Qry`U4Y&-YabM0?ak#FVk(kO`W<8T|Y+!=gnyYUlzuZ~d zd_*Bsv^x_Z{|51cAqLx02}-^^=;(Qs{?7!OTXspJz3+#p#ssKy>z!_!KfbAN!5){; z1HDqkfG3!=5Rhl)pVS&tM06DfPY(UTyNug{T4t@Ya(cR3MZkGy;$Cg zLxyV{E>}#u8?LLm8a+<|$1Onkmp=RyWp6y&tC7H^bQ6`U{bUPt z8PVUc_fjeWH=)xHvKp7%>G^nkSUZl*`pW8skZ&f*%F`piMCB6jhnq0S8djxF zd}Mh?QfGjcD_~|e9fUZmWQ2qShMywW1j(r0zr!4dyI$&pj|OA~G|aIiuAUuasWH2# zLh79aBaNoKLiL|c=PBYaFl4{Ap$D!aW^|Ax4j zK_@1Dp@16qyRk9hP%U&+W4%6%d89%EPe-^}nmjg!&4gE_fhBi~zsmNGlIbN6xkz75 zOKf^-_`^fwRw6AwMC!l3)f^C$IMSFB`v-|w_nJrdV?%6#G>;0Zi{=P&a{P}jdicX2 zG#_3Lt|=7(88MCnl*_C)eMlWf*0F;+&MM`UI_l#@s1kz!x za%@_|bLPp(|IzCZ)ZyA_n#Si156FsVSeVSlAFNj)qrg;-(arHj@?qdM^dPPI@Iejl z3(YfD7u%z=Q2#)^;NvzwNW0yEL{ViwsEBTSx7$(Vw76#)a$fJlAL#>S{+s1Nr1AY1 z9HR=#jtD32Q!U{uVlKC{8a$;LVXuJ@%V?$k8|h~>GLXdGZ z67>vH0Vm26q~2sJ(Pp6|QsHoiT>#4Nl{64dkPK7VE?klR$MR&b6C)s*Cj4(n=5kBO z5!nxe$^VUhl*nQl&RP%OfYK&({hDQl4fzS~twhw81q3jjl+joVN!)TDecN!stu6=# z-c+RyP@WH z9UV36DA4_P#YP&ofWs+b&S;TmBzqoot+~0Lc(^nskr%iGuw06M z&ckJn@!*t13gYMmFBmQ+MAUhTy;R<9AS#3w&R5#)6xGjrn+KWBD(kq2>)R^G*$xg- z8skJoN`3t@?Y0h3{f;*0zU~7d&`r(^x)M_#NYDlSED+B0<13CgP8WdqDK=|K80Z8s z*p3c+;zv#b@Rk!z%eCio882ZSDokGUE(Z6qkc-&(vS>(f5fXQNhp@gPrA}49+<*kY ztnziKL?%sbeRN9V%7(3&{3iv-z)ZE!X;+?-gBsar$sb%u9y@^d|E@3UD^t!`LEuS< zX{YMl3`mfNlY_L`^q|5ikg!!b7cCV?S;qItPj7z47?%JWfnx!;&MfyQZAqI*;9c}j zS`eWnD!UoL5%o+&A0xD}|Gj*tvmV;K;@CIY>?_|F)DaHHjhs)+G#)xN5hPiK&PNcs z(LEOj{(r<_DFMr`q|(ZKO` z)@C{rxy+zMx2m^tnei{kt|*b-d5~?tMN0A$O$2TcYu3Jo3MGU`7D3%* z9|c;kDEn%~&%L?J0#o5===lqmmy{ET6$DrfgCgoZ)>e%8LWPY^c$3H;*p{#sJx2t2 zOA~iS_Ulh;+mCuGNNrAnk?@s~ zDZdRV;x3}O#5)KFB7@%T&uLeRc6|~!9GE_m8fqoo&mj_V4CVoC#YZY14_GJO2ePLgJ~X`bAmNYjp7@>hsxpV?6X$4Q};c)+~b{{I6?W!bV#R}?#CX!@a!tx%lvF$FP=$`|`cugQO- zEj!M$VXepL&;t@QuS57Lhq!g3NaEtGE#Q8)R=mIr0)da_B9;1bv5HgN1=6mbi7+nb z+%Qm!86D6R;-1~fEA9#jHX+ur`Iz%IMdZYHm$75Q)H4(BPApV?{n%p zMDt*9&Hb z)}PKrLp7wwS$g46NmZ8?x`|dK5L@IB~IG$9Un7`Wl1^0S? zUk|=%>>qv6s_b>`i@QlshmgT@)Ih0^asaP*C}oFro=I>HP@I3Ao&4yljzH;$*p;iC zh(TqtrNXTK=X!4U?52-J@8#WTUK@&kKP*JQikHo{Pfh~qZ^UW3NS%Cxu%}rHaxf)v zh_>xvx9nUv@?4zh-g`YEW@A8$G`MH9M7`j}htaFtTLta*b2#_>N3wH5zzM=a49C=5 zvIL37D@T(R#0Ei4&)o->-dTWhzR;#|4-Ka&YgcgX-hok4u{9&ERW;gi^XK;Se~K}c z2(HHZQ6FKg4=+uC`pbGD=*P|9^SjfCZASGO^icGRPF5PL15Vh%m>9lbF1W zHFGZlGi}@Q+Zs?Gtv_Y(pOTl8{akfEc~|kHRoL&~)S$swg*AI_N#F-(pGBCX-=2XR z45SX0=kW^yh;tH6&7P1K5F(rj<4(tulJ6#W3WE!Un2C|5`x=uls7~8kI*`{ z%qDXz4j+vWW}}hD?HV7*$u`dkF>lNEFkJGXlHoBk{%8LloXUh6?xIFu-5x=uu{arL zRkvn zeMwQwMXEf4TCD-$TtEFw?`yr000#I3fa09_;Fp8eovw--MxsIB(&ks85g5yt12BJM z48p4YiFjOiqx1Dt7?kR887y!$oQNU0nTlc^4sp;?MhFdD1@KsfB!lfPB=zux$~qv? zo-PiA?Popw4AV*=1bpKz0?gj`8`54tf_Q&jAbun6#uIR#jiADGdvK1RyuuyYL?u4! zYK}p!rxFYJDwVlw`G$fLjQw9#r^7(J%B-OZ30z)6d3jdJo+f_sa6b6qh+wXZZP^i} zzN46!%0l51LS&zah%&DnxDnljDMgWYGyWp?Ie1p_lD6m%52OX{WfcrMB~f2hX^gts zhX1RDxdO9B;<(J9po9r0L|C`u1Z=XW zV-QjX6*SNj3VL5o1%p?5LXa~Bt8|7vIAJ>QHj6J33ll(%Jb8&<;<)=`>ZDn3C68ZT zxT$Ajw_+7elR1r5p?X)!Xu(1>M@B?#JV8||?XA6r{`C#(g*RNmR|w?Qs#qh5=(huj zlv->H-bnk34v=81FZj(R@L6}64E_9$^tY=iVZmn2fJVpL#Yn_(YU;$Ae{nr#_XbM9 zV-+HOZo+En-n&QO69d!piap80vElk9&|Syl-{9mT>G1bcLi;903vBUX)RfkG-TSOn z{J80BKO$uz;vW|&%ZlOC*}F+=Ka|cHMJIKe{=HLuy1gc*|G4Vuw+93X${K6QXScA? z4q0TsNeLyE#@7o{v3uuBqB+-_Km>v^!7K0Z*R(E4Z$=d&LXgYjD>+<8_@*X%*|PZY zaS^C`^e!F5Z*q8ga|YjZ!%fvgxQgDkiV}zas0nw-DMNjPd6}0_K}oFfng+az6O{<~ z0p3`6ce>-{Nfeggr;lJzvB6n`3KWROd4K#cZ(l$LLA|O{PGjXo5W@0*Fb9WeRJIu) z_3uxC$lpW^hDHe2M@MxNmRCZ814|G_B+7jow<;o>{>^=#IF3W~Z2^_CEruS=@6w`M zA5fV!Q*v^q_=$bhEKOHq&;oUUvRp=ijv~OTg9mTpE+1&bi;_se+d~sVPgCxBlj4l$ z;Hdx(ZScCg9Vb~W+fSe>;g7hdANq7Z`WqnSll8U$@5Fe8CSK!0#v&cA>}8IjCm={rw{lBTQs%&(89{_Etg9Caep%9R;p=>ZS`Kod{y_yzg>2@Mma#J3$hDg~RPVJET40U2*jGBRE8s_9_x76lVd{#lmstM-$a zrecz0Jb?Vgvpg13oe?hg#twF2y_!cNN{!7`3N7I`=v&|32B*a31vKyb45gMpwAvkgA*H!kFx+K!S>Mj+C2CpO$X0(N_;93jA)bi zNp%d~IkQLR;7c|z@oYMdGdf?!K`cNW@&d<}2k)5(2#s}wlc0YqU9Sh@ zA6Y&K?WGp4eJIIB&+Yl2^GnWSgjEQ`qHBL`@5Bx`MLejwM6;{I zyb7S+s}2&aXj%;z_7(ToP@XeE+TvZInkd@IH0jFj;@1*UlN{f-%}pIO|KQrR1vo>= z1Z)1@BH#r@ugt>iCF5#e;8r2|OgQm_wu&mVBgqPHL&za-6syoJOtMi1k)@-fvnS5k zL;Gc8GEX+#?a_1})7ag*zuPgQ6t*T94ofk~j(=-ZMkDm%>k=70GB1>qBP!3jht%U1QUzu-cw;}PGK%f;7rjTNtD4BW{{E09o5^aJiC?=x z9o+@UvfpaW;$@$A-@Q_sSe5R4vN|5=+9=(BcoJE;HeZ?NGMMm3F_=sx)m;s;M=pA~ z8a{~WvPL2q=o`Q839lc&6BGRlrjmt`^%<4Hs?SK+04}b`*)vbP$?dU{8w^j6tFj}jpKzz6UW`utF!!)Bn%W2ut7&* zVT-zmjjs=qvS1uq*RuKIj|bLF-RB*SY+LB30}=Os>7)XWD-O1I>d4Pdt2&>X`92vh zTb3IOeVrS4sV@%3Y-0|*3FjOkLes9Z@e;_n>Cz?BTk#2=)S!#o&O%~nVIGRP8P~yX zxWBPwM8+l^xpsG|nfLe5)1C0^9{R-9@WCicAWDgx^Zk2?8LOVmcuB?MPr3tp*o;9B zPs3$?_8Y->4F+26ZC7nGq&5)zrT^49u z0@~f^qHSfee-N$^bC$`BylT@feMQ+aG;A6f#Q>Iv>m!n z_FkJoqByimG?(pQedQOC{q_tmFF8@;ag%a4ejzw+3Jj2w+*;jE%|^qvv3pTND!cH>=Q_$3t1qzss|Bco>s|H`C~6@IVhYLh#7;aoC7xKx znlZx7m}3KN+X6)?-3bx{OktHt`6UqZgI2)Q73i1Kw6(m3aDv7sSj|fhEFB_X?&QB~ zKw~?=eHS$yLmG4hqb}ry&Q3_=a;f$QC-c@5I%9_@os-o)JXq4ry~#W)kav&SMR#RU zYOzQDiK;}7KqVumZswO~Wmm+J&T#y+`7XUgKD{Mdmjjbq-VX0{wh7l~`ZLY5-F6FX zusaKiwS7^B*=e`UxN%GKg?a3qgxE+hC+Il?$T`k$%MFG#ZS55zwniOo`0_U_Li$(p zUA9{Jd&$@=k`%M(ucP z6nvNOxE)BQ<_m)*^V?@p1&R1U_owU8&vQ$njzSjVn`4{ZlV9eZ-nqWpg#zCRrdvx- z0d^ku56qP_OQSH^&;Hj#wUDq86khw7ZlHfN9ml7I5;Q(cgn_obn_vKQ{qhs}%*3re zEOK)X(A#F_R2*(@R0sDz#gea(NbaYd)_vzgv!AS8Quz$NOo^MH=~ggLyny!Dv^QC*BNO^Zjst(!_k56B@m*P$;=|Jh zW}^7!QGLY7hp-Wj>cW?Fjv07-V9t0J2ERCM!=1suwq7owg7bjG%LMjqeEVo8?~T#F z7cntcv2lG(Ftba1XV)%XsOiF>)<7v6n=sOpJ2d_!<*OiT5(>(!e=lTlcd=4YW&zy! zh)9Zy4Y-gCi$2e-HC*1;%m4W-V>h=u{Y{|3>~=(P#^OwRGE&A{AjHO;TQ1!y&?@go zVWt6&_H{HXxI|TRVdJe3YO|dYeEz5A>&tjTonN71VUR`AoB?4p2WEHE-=`Fe=%qG4 z?@rB6sic!HYy;~flWD9AG8GIJs?#v-j9I}|&?$%}b<$qF$WrTb)yx<%BOM<+)zF$( z9f=Xpz1y6n7$VReI^0p(riwtP!t{7F+L;?>0dRU>hD_Vg!^vO#@RIO31LebjlX8ll zRYAtwMu%2V3z!yDmDoIrl0_kYghmP#W1*#kc_eYo?QCG{65X#+f|35;iai#jH58Dd zp?kOeXbhSxrq^wLn_E!c1E)ROsCW3Y39}aE&-Sps_ETp%%6>ayCMJJsq&eNvZ-yi( zj@ZeXZ7vs{A;jIdTPdd<65cHQXK^e_Mp2VZ+h88TIi*>r4z`T8<9+^&!_wf)V-trv zDg>2oMOEFGN?KZucl>cgc}46Cw3DZK5HF4IdkPmS0YCP39}_xqAwTWl;MW=<&hXlB}WKfCEjK;tyo2 z)2f%HRR6pqa9kK))PV*syk`21rki!enJwvFFD2qZ8*IjjE8ptyYlSH6jwF_M=^XB> zi+8EyggJbc@P%>Zt8#(M9e;Y`ElyAEh}5XezIR2S2bl8^6z#-H=A*E|T*FvJpe2Q! z4IS)v%z@!b#*(|DlsfsapKyT({S9LWddPvM72;PkUQh137&?@m*D*mWU<~AcmT32Q zqjR&f#Jw}vbwaO{9_AuD2yJ*g@OB<$C)QV=Xug`}5gAvRy-w_V9Y0l3(S}G5$37Yy zoD_Kx4Vo}c^v6~Y@<}1*qv7E2AB(kOdZSS54 zjDCG>*fl%#Vc!dfs}s}5<9nijlk4I8ssMpvMOJ-*53WpJ=jZ7Iu-TJ@o|t?engbVx z)YpI4Hd8a$ zU<%N+X~7|F;l8d;z#`6lq>aTN`u1781G_$OvPmDIuB3(3EIc+Y$~LmtrJBgtRYHH` zw^F>2uLo@F8r$d*9_Ah))D?f^VUJoC4y01k{V&(s{av()@{ zcr(XLC~(|lM|0@Xm-HS(+_8(HdTD5lZ+niCwkjCe)9h~`NNEv2pNQbvs4}f|ol?m}RSg(n3CGw+= zE1h3i$v;m32Z^QqRm)lC&q5X+FD`8?c_v5v$KHbaj=dB!rSFV+0sG4}yu+lDz4`O}>xA1$z^YoH zu5?$~2yL>3A;*u{b((^zq`gveW?}{&TwYLl@8C+jB(d;dKGV6kQQ&NzS53yJMr&GW zC*{bz(|Y~Ub*Je@oP^lFpf6BsBo0sPejF~9WqIPwyMxU_yALTQlM#5=e+imfk`YU6 zs>B*DM@OMI3NfA(bv@~~mv<+fhqhKS{%N~atl9FXD&cnzI$hCs`Q0`i8^WIT-{|*y zXY1SftC~Uu*oxMgl^nLGzb!-w5SM+LZsG&zNaFD~s>frQ^%RL@uP}D5{p)`ir zM=Euz@bGnA9kooG;K!0Ct{l5j0%=1L7Pj4Dgi*(>#M)+l1N?PtdK0$Euec;;G3md+ z6z{lxv!xLrjfa~Cp_Ey6IPW##`2s$%&&p6q$Ps5_)dwD>lGWuD1#nt|gybWq<08Hn zO_TOCDO{Bw#gLedmKqWm#G`%POYK!M$T_N*a}=5v(6t{k&Oad44h}{}S)aL@CU7A_ zU665|+Cap|6_YTf-HI}Lw)F@nvQd|>f3GeM4%TQEtK6mM6ZSDkJP$^K>%AwKH|1{HxW-F^X5KE})nGKbVh78VU!+c3;kQEVaY?iTu-%H*qF9Z};IS zKpvc#4g2!Yk^6P!mk@xms%N4LCA!1DxLv&N1KlzC22W^b;`8gTzt#HNAJdsX6BjAGtruq! zPkAkoPeN`a8a<7tlMi6TrQg$WmdGdm$wFFW>#xyLwDY=_XRwpn10RYK;fqnB4t$`L zM>=r(^C{ij;kU&~Kd6U-JU;ZVEw-1WCkxED{DK*LY6;M4 zhj!e(zMwFOcWU~&bT+p^!=*B!NZkE0gJFCTIzeKf;)h{RPw{cZd>^ozBrw8h?^B}x zO1X%gw@VIbGb@s8rlHG0k?K)XZus{c?Ul-o{ZO{$3M$e4|t_(;Q}3%b6%93Od>H^zWS;MK*9r1+5E*I&g2#!%Yy z1(j!UMW>73IlAg{&rM8>^d0Gu^7np^wkJ1ppyKoi)~So_%G20v)qMeIi&xILl?iSK z6aj}$X17jeJR&v-BIu|F|(3{3$00hc7>n(Z42>%ImFH#h~GN@X)ZnGAe~OxmcHL* z!RD=MqNdX6)xc3T{9vr5_gAiNzwSWcj-y&fCZIcoE^a+G3ip2I>kIqkNt^P1(iXq25%eXzrCpwNpf8gG5M&Yc(b$NB%c$f8ohEb1E%wZcN&L3 zBytnO>w9ZuK*Q&$4|%jz-hnC~RYEw^XPLw#6)0a-KnJNP3=v@RhnP2WDX>R#@DnCz zrWJr0Gm}T2tl2`z_0y<|3*fux{c1cJNuh53RQF+EEs#t`*2W~($uyp2i~f5d|GX^B z0BQk%Y|r<_$4M8-a>V7kFGub7GyytnC*_O1)8tP4CqxV>2u^;j0%L(Gy_fv%f!ndM zNnjW_&WZc+5fT6D>{+>D0pf^GljI)sjw_Ov1;|c7! zpM)iHCWJC%-lRuxxkBKu&#G>#%OH07K|d*z*T2&( z$M4aMGcao{k}^r?s_AaZ^6GF*>~Apz(2Vm24Nq2SLwSpKC z`l@^A@mVZr2u}QI?gbdLhIUZ{#<)UPVyT5ZIw=KQHpmsIsrz&mMe1ayv9K>Djz#N=X#c{qWOnp;IpAHKA|jIiYh|BtJ;fQmBe+J*-aFc1__q!keqDd~_9Nonbp8oIkN zk?t5ehVG7$Py_^qh9L)#&Oy52KjZVh&-<>VeeLU<%}B(&xY_mT z7#%E?7H0l;Y}G=sy2bs>Rk{M^$Xy;jNWkLnH)Rk`sXe)H6m@6!iOFHPal8%MyLC71 z!F6tL3aa9y%-T11jsPZ!zDGszqrJ;|e2%7HC61gy%j57m^v;VukJ5>@GjKJvu3U7l z_vEn7$c!O?1jox=*#geSc$%+Jt*E}fVFC58vz#WcFHU3)FSpr!eAa7`O!uQ$99aLV zynnm;=t8Rmh9)X@IkFB&4u~(0k{DtN&Jw`jUN-JliCO;(|4zy|&_nO{QrPg1v;#L; z$E-5`lcNLkZ!XJIttvfowvh#8z-s|p+>0ycd$KL6r5^nDXZ3}SY1Nc<7iJ3g9K%d_ zltp0-UvdN6L%u=4HO;cg;}0{p*+_bjhyfP|{$T2NuppO34$xnV$$DF}<5!bJkEdU+T^=9Njm8Bx|hh z=`@0G47sY9oHFu3`v%_9!&fB~umG4UeCXi@f4Jy1;d%=!1&n=Q?q8(s4u-`$e?#K< zWfeodO$nPr!-Hj%unE;j*0Sd_KgH<#?|K8jOxvt$e@MJrG~E(kDkt7pC!g%h_pLCPo^HJPtco;{8rKM~c#1T+x3sD9 z0kkj{i*MF`z=rdcysFh4;a3fg#GPS+9#KQbuklhu%{c1Vr@Hq19@cfN>^A40IU`o2 z`W>v#4{_ts2R{{?oqqnNZ3(m)L|-fBs|rNLqkqcKUIa2X?=6Q$xWpP{M z56PQ$ue|927kG_f${tnT{32p9A^2q7GZ)v{)Q?fc4WBsXu2|w1oFU+0@ z9Wn|{+0_5AhqB}&V82Cb_Mg-Uai*$Dd_2Ydt2?(vFs0m>(~WMif~NCT8UAuSe=|2R zkSRf=@|Z)||z-O=XZJKmQc z8JT6gXiwsYZenQ$Sp<*BSK_?p9_Uv$Ek5|O2)cBx56|EHyw8tA_`J`=#S`ZaV)AtH zSV&j(+|iY*bGACRXqG$PpTcm4sVrtoF2jNni=i z+HN?L#8Tv<^=IAz|4$t}_b%vHfWH?0K&8tCQXS0n_#FGl?kKb`jVwyOsNt|ZIQnON z(m3ECIF&!Cm|%F$%2j?*bHIM9Z8FkFYcx3ECd)|ihl1HCr?`Cl`|k=%4@=p4$23=H z$CE=rqP_o%V79uy>D@f{XQ}X-_LRHAy&SbFroLSFu+_OJ?6+56IPIUbW3{xI>Oepz z{Ha&TAwb(J8-47j)$R2cp2W5$)<<%Dgtn8>UcF9ks@6T)^8^Ng=bg(BfSnv=wx4iw z@!S4#}eAOhw4eDa>Wuz-#>c z1>`3GoHz8aia&e>w?B{wOd+g#L-0FbD}eCRg7MOd!u9?#Rs843Z`L^$l;oUkldy{ou&uCOY$yf9P7OL_|6}P0#;Oza` z#aY0uX$c9=H-O4m1LMuQAbf%p&mvfQh$D0SgK5C5#nDZx4Uaz77{k}07fifz6K$rt zawSb-VI|OKQ_pHqgp9y}yX-)Am^<1@b_k^tsrs@YrLc+eBbP5(>{}A@ggW1DIZ_pv z-8Lx|9xm|(k*b`*^?lH=KKtfo-BU%#3N3HF=2Jyk68?LLlF%&;yyj20+_(nVQCRjE zTD-rbmvYWFRj^rZr`2_NEl>7SRd(ym_lJMyX~Rlxk#y{5wj*gZki~zVA}KYHiNPN= z`z0@qCrZwX?WlwdG~bPkCoNv8_r9w2dp_$otffe;nbzSx=$|6&dABo+c`3M)(!*n` zw2zOIjV{G65`($j&-4jWny-`XtR#@prHt6MR@!|`(K~Fx%!+e zIxg#3(lQuW8rgph_fi48?!GD=UsV|x1ND^F{iqJt8x+w0d7>~}dj*+X`#gL_*dZVQ z)BcC9TQG@$Qi=>$P(0+)(lUI5^Xbo$ajqtYegJ(PP`yAYf?bq65i^VHU6(|8ifTo_ z3Z|Mb$HGSJd&9jy6Xs1Haxm$1zta6>isDQ}mpym|R4F&)D_v7;iTAuk<67(|+eq&q z1pHjI>kcp0gR<-A`r_cmD;ot$&YCmQ&oVAD&U&jPkt@CgEZ(MDY{r54=@0t*DGY{+ zE=)ql!*c6mdwFBU4^`|CqiyUUDK;H`R9cK)bf5T0r7? z@knR-!kEiD04b9FSTofang`MC-RyLvX1}h1*T=By74wE*)f%dijw`7S-uq(k3)Jlm zU{sBx5b1cEVAAaWJYF{9s}SElZCXJfU6CP3jpO+tpPx?at#*w)foJOqtY4QC7B_vm zDXOn8afS&V5HOwFuzDcwTJScdaREb4b#kyLB;Er3`&nBfAP`H{?cC=i(HjT9*TV|>GK|XpKsLGBrM_&(+;A6`*i8`QL>5L zb7Q)a^D34{_`Dc96M4_q_A_re;v{c&-==q~X(yPiS3MKm){m`tT$=mSO~qFCgCbKu zcKW1Ck5fAGqzf;#lu9M3U%@{0cYW|Cm4EL;9SKvZ&A#&JS5RXAobrOtus0#kUdZep zCuaBDK5p5w*7_U4dYe&F}b1mQbVRozZyjNv1PG= z=kFC)?K$p3mh|)%Rw001gLmnd(|OVY*^;{3wv#)E`yO^m6VY`zX1^saXkDujar@=q z=Zaw2p8 z@mwRivxXr(A{+stG<0deP#9XV~ zW6*#$&0+Fz(Jhhxi)$y^z!mHyd7lC2Bf(IGV#@0o-{Og20tazJpI&?|;8duG0Mn_%NKqs6_dCN*}2ZPXXYh)W1)x)b+ z>D2&j0x)GaSNcW1F_R~pW5Y5GyR$IIrt^%2<2PH0(J8NO<;BWng!#K`qQkFaF#sZ=)lY z{BKpAg0Z}(Tfc9zP-EyHU$tV9+N=N}_E(|A{S+OMS>ZX#LOL<$erBXh7w4X7hJqrars$(i6kmiuJKILBFjN0yLa;~m##I`{hGbh z(PNoq_2zT)3|&gV=zRP3-Kr3VCW!q)6pg2WIJ0bB{y*OxKwb_;8;LotQv0%sjy5x6 zJ)NR;+V(JlTq(82p{u>-_QnGgTfQ!3do#@fZ74fVUdB&G(5+SCQwoN^CS-Puru`2n zDrSBcq^kK2^Zf{;4*F9>8{*ZU?pH<()&@}=(pP1f$=AdY*6QO#gZJ5IU)ub$=P7zE zBS&~yH^P4Xu-RO(cn+}r5Uu&)29e9T*GI51*`VRdt&-}TQ+wyZk-{tkIur67o4;0+ zP1ovhrfZl%i1b~bDv%S>$C@y`#g;z1`!v_|=kM|V)dEl#v^4Dh{&Rl56_fa?J~Hb& zF8d0DiTO%1S-Q6cuHlLEn~cwY!dQA%m{#N$UoeGcF|_o^8Sv1anQ%Qo`I;kn{Hgx( zQ3o&P-@#mF4m|#R-60Kj@dIgaosHltK_5ediPI{CqMXQrhz5JP1>~U#)c^X|ufWK> z^=AdGU;iH?=a2;LfZ@nuZCMd?XwZrQDh(EAI}f-0;xBR{yw0yL^lSJ~_F<}nobG2g z`=kTbGWW2P-wOcabB85|Bo8^juv04Xp^zkw-*ss>kgvSZ(Qx(QViY_D+VPjfS*trr zqw~hBIF2m6-~)M(Pi3U-YX}6o*C8J7UBQ^~a>Ue`f#=XOsb)R=@<*=slulCkrY|{k z-`p1xVljl_Ed;+&Pa%Klr~Eo_Nw^@#YKNruRGG5rqXs*JEWIxdD8d++Hgmq6E%>jM z`*cT&W4>p6y_du6;8k+-)2)WH)Ii6b;!MKl7!M+cl^KSk;_yZWRr?h=AFC%UXRfXE^Xh zWK9<<65w`$fXIuJ(T&6qbYT70z^kFvQsKYT4~K;87R7^xgR!ieDWv{qHthC#1gs(B zvTzd5H3R^EeD`Gg;|6~9%mrXj4{Tbztr-+^od*uue5of(&QlHUW~zWj5LSH@K^Dgi zbU*x@g$vJgf0pN)yu|h$TzVZYU+mzL{Nn%H+hPkB#u(tKc)tD!ikYuqj-I3Ex*WI4 z<<~MEKCj%iU4pUw?Lc&k^<<>M)~xu;DW4m?20sjL$u?pmmjv9LHYkG051kWKd43&e z24M5A^eb1pnZjxNu-WhPYdo?hjPu)$aufUL{!2?-%~z9mpBZ_Wbl*Onm|}D~*#S*s zsj`=MLaumCj4tY|N_{IhVooIB*lEIeb5R2Vi;xq{B`w!k6-=1G=T<%;&0Bmp4t9*& z3m^{yb_7i=S}Z-I{T_ixC;I+S5PZLX&%OcKSu4Avlg1a&B%!|;4&B)3f%Y0Y8Ek!Q zq_XZD;d^))`^OM3oG?`Zr~)FO65rq-qzQ>rALp$KG!cO0tQCSN(U^e~Jto z_IUXP)uHUh(HZaboz*zS7CxfW#Y7>P23)g+t6gL7a+MEKY-_d9VP5CvJ)2!9*I?SG z4B9W`4cI;+P*xSz)#v%D?-b!Gu zzT>~51nv79UCJBca3%iQXk&PIg&n0pMvp6(j7TS70bomz2aWW554{7_7}xn9=pV#z z2W90mx_kv}K*hhh=Ck^Hs5_(+^v3a$rY{KR8veWA85BZ+)fb@~SgS`}njHRYH-WMh zUr7LKib|X`qkA#e*?trd{WE4VR1z4oo$w~8FRL)~13t zfce9p0|I5C`6|S;n;p|r#VMWWO)I$r4#&fXKnE7Tu$ddHpn;WXAeMEph0Bp`b|u!9 z#kV5iSB;yif6k9fViJ{A#Ej-!dtPl7B-(mUoos&EIGHr0yatBf{>%{fJ2{l~IUK)7 z=B6HCKawP=)T^cJKN>o3Aj)Q75o3;11P1T2-AA0YLl5CF)@y8d%;JI3m%V5( zS1}UjveiINfGESC}Uk!T~>f@z{y} zo!Ypal`Tc5bU#OsDuNGnzE-%iubao8*kiNp!d?(#Tm} zUY51Z28(tv(~YgwKCz*SvC#)gEL&^g>N)L4?402=+LK0}e4--MZ7-9aoBQf1Jh|_o zX`2l`mL9DfORl0FdxIu>R-m{t(tAeu3o)`VtFnG!r4Hc%D&JPaG1c|7_UdL zx{whv(L?X{Qd_#F*eG$%P(G{G*p>Mb4h{wkZKsk7BfADK&pweiRR>a$yZaj~W0iZ{ z_E$9>8tikGi(})GoyJcB8fx>~D!nziX~pL^9sg9%xDQ~mKHo7=$WB-g>hX-J-^~BB zD6Jo^PaKh&m)Bo*w)Vwgj|0$;$bAF*kZJ-g!6C`u0t&szna6^i}q_+b` z|4~Yd`GB@C%Y0+{f!AG>_}KaXQVv0iQRb8Vn*UhuihJSobR8s{D{Vx1vql@tF})s$2c#v;=*HI0PybPue@G zBix!c2joNgSfv!e4?pJ*<7@fmd;8dY&$PU19KwBZadKyd)HWigi`T;9Ks@i-K+DYf;TF&sIX@K12@)oN7&pvWXb$V*4y49V&b2 zYz1JyPKdnF`>^NVs*8iBdIsRIIM!@0`CS51VnX(Qp@n?Mm>oZYme0v*leM6J!Wvzi z_~FYW1^KG`$a3;Tq!s?4yu{J%r&wrmrdYw`p|r0HL2+*__|y6qOr1So2;JzT?k|tN zSSX-cIC4<(S;v`*9r{VFJ83kVXE-?=GGavG;HDrMxHR+0q*@zCL2Y1jX)`yJcXv1* zd&jN>5xTmN9^>6lHX~$m<4v&Oj7fPmPdiPIo6}@je3j|O3~;=jkkFoR+8=NC7}(1Er`8`zYv^JXQwNR25gVep$TdKi1u_3ejVCoJ??<@f_<`d=V{v z-bz*#5j5xpoq7kAc#-&!NBQ*?t0&mHYs4by$M<4vuG&`Z4QoFIqTKuym;Xk;x;zTq zA!9s@puT)@Ah7NWEQt(z;5xp|*F)tU#s9rd0i#d6C34FmHx$aA4Rg|YPC4G@3ifZ5 z-?C@~T~|b3Qfq$Yxn^f$nT4D{@=^6(muT@4D4lM_a(oB;NW;Q8B}f~wpIt@bc|5Ew z&oCzx;f(aY3o7oS6yqd<-}c`|Aq;JxfKfO6a0J22d?V`s;@mzD*A$5ESRcez-`9nI z{6e$i`P~x?{0}btwqF9o0RS0t)?(!|^e`@Lv;!FXifJOPj%b0Zwe<=C0l@Vt z$gi()uS$O!cwiInt?4@3pf|rrMAzhg2LO-E>`0~Pw@DApsSoXQM;FC z?mk=PGY12LQu4ppy2l?9=XLe`Wn*l{vPv=!xxPNw>pW6i(&s9CIMgNZi>5>tHSa2N zwlpn7IVMo0x$Nb+Q^qS7Ks$E{gA!ydr*<45^2lk_#Pwpmoh>HA`L%>Y7r;l{zjiXC zcwZ>-w;+-E^T4S+!irQ1MS$_55zz4vALbp^_gAL-?81$i(r4*>W*l@W>UgbCy6(LB zA`9;m97L5ZMNTz!xi4X{z1@cIUomh#fsk$eg5N12ZU`IOUZ?EbOT@0MOXpIjrt?K| zCs!qR`o~`%Olp0fn-8bCU0z()_^fm(d?5MCXYO&cr6J=hF-qf}D>X`oOqvP*+f#a9 zllC4-MOTmpCPTTaUi8}7f;4b#IqIZGf<&2263{u%!%hB~Nv5OzEFoY!@ZV%nEDC}d zEw_oAuUgyv)6nA-0}5vm?7)_9!d~}QW&`@t!K7mPXqBVq3WO8>tc_lr;lLhZ1$NRJMH<3ZX%``_@XN07q>%8Ym$&q z@uSExv}cE!CJ*aN`vg^A@9(urs(P+ecPq;Mao<#M>TwqG*emJLZfxQa`}r5>3zwXw zoluL4N+-l!F0}X7RFkJKOy?>FUNe;JqA!}Dhnig8Bp&3E$>f7hN*IhTb z&IJFK{MUULoTqI-a)cqUl_Guy#n@QFC`gO;p*at?2}m-zfZqZA2Rs2;!s&wFrGFBo z5}oed(mlpgw*wPG9G ztMQ+otdkH;{-zGSmL@Q65C64Gx(m~0SR-L)B01}N`9+DGJDkbmk-DU(#3r2(&H8{D zxtY!T>tV4+^$_g&K?%<7kUc3N{|h&luoO8N{E*%1`#jG`Q|QWVo3hl?ST*Cb(UxU| zr>Sk{_T8Yw@RCycq1i2$qv77o$${5!C2A9B3vU&9KuR76E`S(U zkediV75-oNG1Wo>2KFbGAp9*hZ&$U<-ta7nj-8!sV%2thMDbn?@ zQe;F^1D6*#dRfh20ucYLc}3JgZa~IL%ICa?@?6eYX2_5(WIql!scKZ&@tWM}Hc=p1KR2Our=urmtL@;zeC8XG2*i!$KM<8=9&GA|M!6w>HtjcPC4{U||a)}>g{M&OSK*0=rB zLxN2GoRbfa$_7U(cCMR@%S6@0l>a@!{+0<9jm`wpF`V)|;pG^Ay`IuqwYsA+wqEl; z+N2NF>ivsdeb)^=yNS&un8vmZHebJ1tA%mGzZK5oGHp$5Tv8+Ly2lqsxjhA5-YcDI z#MbYpVeGwB?KMD4swGQ%5`@c6z9k1{(vNm@V)WaN(k4ls87*H}t%Zl*4oi;r%W`vH z9{whSkDkYL;;;i;>6<%*ZZU;gXFqGamq;rZ30G%}<>&cAgk1&P1Q?pEbBF1lHUS&- zWbA^U-KNRxZ=g+l417`n*5YG<v0D|3egL?%krpL!Y?Y7VtTOC$ zJijNVR1A*GVV^FQ2i~5mgubkmT4Oq3O$z?0a+ZIdDv@bT-EPgvrvd`*%9P72lC`IN z_~86XGN=9oX{3>UoNyeuONthcGWz7FB1Ws3%OzAZPWN`zmZzfpt6m1(&iAvDQ8j_# z*sG!0xl+m)N|*jxJcj^6GTA!Kt3bMw-&o@2SPF0a_umeoc?*+7Q`5~h(mK0eIo(|> z>uNU&kp-#OOUMQ`b2c-AAG}@7@>j!pd*!&M5_LOhR0C}4vr9@~AZVq~fn}4P92Nf| zP_iY8E#F30Ru5NPq=Vj@xo2+GHyI8Ej32N`I3vf(M`z@wJ@gRb@NSi3=mnN#eDRNE zq=>|XFC62FG5;4h-u(UW?^8wiI1RSbwm-d3l@LD5>$Uo1*^fqW;PPct~?L~{D>6*;0<%Mp~9E^o8 zylV>@mjpH5M`-ts9{5C)_8=)5u`O z7IXI~94_ZD+xYvx1hK9H9*X0qiZ>6jXO8HFRRO+12l3ZD1T&ezq1&jmfB2b#kO(k? zcaEp>5u5hcGO`@z68L$x<#uKeny!s~zt1DYUY3lE>x5T(IRTZyc>f+|(QxgXCM z>m&)|d%EL;AsYDnSX{NxhP2JJpHiX(eKcO0%h40$rqv@NtYyf!ya$l9xHIxZdS9p? z?S7aCm9CcaDg?MR=imCXvkxCehp-$4_6ht|@xgGO48#v0)Jk;p_0`@wZ%m(?zD0N| z6%0>-=W$oPgC`h&2PvFF#=u$BgAdx0Ku)+xk{2tq-QfMSr0ehAlU>WaU>f=NG&L;Q zsCcdsw@B$*T%yNVRVhQFz~L)RhGt?hYtH2%(PAS7)qafZWNs}@z z=qI3l=3-QHIQkN}GbgDk;Jy2!-@S#?3oZHlj@qz;W*Cu>6 zuU?fV5%;cZExODWcHn60af7a9krGXh30-0bB9B;L^)T6&pK9KU@_DC%GZi*9;Rgfq z;9;}(>BFyNPk)CHQLk*-Mre=LM-Zc{f~g+ZVq(T-0riww)BDSpf2P@AomA-1?bvPr z6+h-@GTT)6ZJ!IsmF_{^cnkVZf6Oqvje9+JN|08u9gG%Ps9Vs3$717}aMgx2BU z0Y&_O9vQ9hJI8_0U!ZjfLfzzj+R?tUvxxC5Cc<@W#-4EVkT?qYkW4VZR3_AQ zw0@*Ww4Uaez}wbym`dS8*)w_55R*%WTF z#^A&D4H#ka=?zCde*V{E9qb<&EvIca95ts!5PVz_a>jTw#w>dGpZLv6qbJ*Vt!kIo zo(Y!()fyb2%0eqNaUlUtyFXUF1^G@>87$SosIikRPS?Zjnb{ROAvfYCXH+6h_>!@u z?@?>yl6;KHfwu3dI_W{{6!DOXH_?mJmyDK0Qu*z<*1mqYk~p#1GyLIY4o9R6tb{c9L4q?#TJEZJ zSwRk$hh+I*N%AyBeq4hfj|PprWM|)u0G5`&4Me8Ng?o&{SBSN z#0JeCd(+b-c+!9;1oDvOZA!ooYeJd~uZox78)`Y_QquHhOV~xf*RE;}wX9S06lv>E z%5Av7dq-amE>Pwv+{^L57zT;*R6K96r(n_b&5^=ISjW?58mnT ztqP*+T#`|)7unBTF<;XjUwJWDUgpvYH)SH`>ZNyFpL)KA#LeAvPv64O#Z|MqJY-lu zfby~jXHhY1-4i6dEPd<}aD7pn0KCfmtuPfbU&D81Y~r}!pAZQ9TI7{qqe@)MYj%V4 z4;LO5_z?KOK9z2+wDwgId|2EZ2l76@yoluCccV<)3@>%#pyB1Tl$nzaGs({A@5~m# zaXUC+`))MGw_Wu1>vJWFMr`y8c~Eb0AqX*i?s zao;D`<>n{cS)~rmW2od|RwdgJs%1&^F=*Fx6du2{}$0yxOJZ4c#> zL-=arEWKF3Z1YyN16ygR|5cmkRKBD1XR(6;0(th_-CvhXk}v4s*FXEIr9Pw6Y-@_g zfjKIXL*6Y%CZ~J3lAV0w+@cY@4ZJ8QxwLhT=PQx&0dv##*;DQ@brjtKZpFrTgf7qB z&dluJi-i^6J9Lu6!A%)OTV<0yr~)Yg$<0m(B%_!oUy~1d^+weuj$y4%Zt|c#5QRDP z6!APy^V--SjE2WeonAG|HVutRGr6(-{3L zZ?8A~cXMa$bPWxzrX~&e-}}*o96>g-fY9G?9V0E{^$7TyRU*RB5*qSUp9Q7g_ojyV zf3*Og)o7@I1t4#cXyA&gx`pd;9{({}I_9#&W|i>f+y@FsB9%RUrs6d%(D)Ca|Xg zUf#z2=S;8HUmAi9q~#Ot%4Xn-!gq^Ytj`1T)sZ|d3Ldv%(<67XuN&aBA!?IS3vVPyh z+9{wgs`rjsTk~(9Rqzl0#FNwpx^L6lv1;Ukx0NN5;VrVKQC*_;ySM2>l&F7?Y>qm0 zA&poQdXspzhhQsx2$yAP2K0UY7Xh(_U-`7fx)!au+B`5%#jQ%gMN=EgHUowluAjrf zLH{V#D%W-8F1!r-b7lR(Ski; zmLoA72!we^)1a!3R%P?v(dna zZHj1e9A#U*Atm$$tt=gHRfH@hEk9|r^GBGl5mXb@RCu!I`q3#?T6@@GSP+>|gg--Y zRvHlL|J!|HL-YIw)>7$DnL8Q>a3HaPHnt*E-eiJO1&(ESkfFJ^5l}^&lS;ThF%6!& zeGR-lTF1;PZp3Q09cI&f&~=+=s&Tl#@mYZfq+6ed#RB(jE|Z-_sD-1rSg&iG%KZLZ zp`!lP@z*R9+1Xpx%4LTm_%!f#!QVPHb*TKVM$;@XS}x$NOC&4GbwX*aaynr&PsexZ zPfd5?ek}z;bI}ExNxYS8)i&R$nOS>U(GL^%!E%Ywb>jY7&9;FReno?Mdvv4b2Rt;i zy~7r^Dy&E1SR3{Q#Uvm6MdWS_&9V%?DH4?1rF7;a)DIdq)Nr#F)` zXkvAFp!OvNo&%jJ)GDdS(lDzYcebJ#Y_eTzd$md`7NjzNoluDm|V>Ms$NPoX|}datB!L2 zItdqo0$G`_>J)T&8^$t~EU@`5N zRs9I>2)OkdkTxMQ$_a4$_ML&*m1~ekC8yR_l`48WXS*Kn@gdi_mrywegQz~kw}L#t zY_V*zB~GmI!1FXfu8B2}QTYMTRT_^$KXZ?r&UaxRO!qYMV`-tE=-DR)Y%b4Y z(-19aq`?M;F49GM_0|1iO12L5wy_%!$h+UjQpY0^Sv@XbC6MUE&2}A8B|rHT1c1={ zZg=wbcdn}U)+<|J|CAx6WJ{uT^lO{oUA`6aC9LHA(~nHvkOa6GSCDhu@N5s0LdXdw z!kLrpcuafF%=`5lSNVEIP`VzhEWb20@?|=>I`t89Q#9Cc}c|8X#(>nv23-#IZ z{`IRRk)jlH*U_sI4c=%2|5nU>0d)j>Dz|L1x09EL>k3ilnAJzS{kn}3zdA}DX;=^d+xuZ@+iJ`9d9;J3V#fT>W6}j}pQ8uY9UZ`z+S7QKHH-J?l;blM< zK!@=Q0(dl2*~tXC>Bc1_434tMFr(t*X!Hs-Z&azQ-uSc|CyiR)an;W>c~+D{S*%G~V-Pt0ipI{lCeT)y zMsfong`AizBVXY?0bdUPoZYlRb6TzJ-|}Ts{-&!IW^M9~JE6Y&W$txWKbB``Iv$+* zHS&q5(87oYNE8N{@9f!0jtZ3kkeSaP7;67)qCuDUa8=!#uW7!2y9eRdY#|qQuf;%< zqKE{C!N`@mb30~#=~=MIJSO2SGny_%2G89;4@R?%v5(?ra!I?TdIwLscmvq_HEg1{ z|GohF0X8Us=b_F6Z;hoU;Vm=R$z0k4e29l6)Gu%8EsX6>D(;Vio*K2>cFr1RjUu>4 zOx@u=4~xFf$)#^5?zL8B6RgkV<`1?*O+KrnKc6i!ZP9I15Ve`mb3u~yMXMOJjTUs( zN=Z-Z(O4=K88oq91p)UoP9+h}x8A&cDuYq&_J%86c-C+-rZ&+@Dk7LkVSazvhh2vJ z+11=JU%dY)L>x!0M_AX4Ch^cu=HhR}cs3`UX=6R68|j#{K@USzcq>0uJYtzCJZwG> zAwDt%AXxbajJWKR&S+t!gsHee6HrVEXH7@2 z&XNvG!DtSOB73>)vdeSjKFj3wfVUk~ci-RDvm#8z89J*(WF%)E4#2b;;HqaMF$``b>VUEf!laQC9u&aUNF^L|U&p;FpH zAPNG@{A8fvY<}%;F!&N{mz zT>QSzLjq`In%j|ojEmtahnd|YQ232}CIT_9@pLMJMnE5Rakft&#bb7?8vSBt?v-TIUQG0Z}wYQ3D(Po3cT*!i-F6Wgmmyr7@ zjGU45E~FdpsbcG+#G;DbGS%37F@aLu7G%%#5U3T#69Da`|Z(zhnxVHYU0D8rg=VId;rO(7*4 z1E|u#2l9YZw4zzhC^sKz-Q=98|8XGF{cVR*mX z>r5PV#?G*HIWNLsnJs^NQD+W%`$+_tOS5TAMZ=!%KZCp+_yfrx`HO%3;pcJRM2asN zH?l=^4Me@uPkPorhMo1xoxXV4Af**ZRQG_!Ji@XPWs!LFZMF{rnQK2)&z^0+g3C;~ z4>iC6YFGAU90G6$Us01*e#k#nP&@Bp6$bWK+~B-NFbL||zD4xylu+SVfC0cfU+o3C zF&++glYYAd{+fejm+hg$j|`04z^|qs80TALbfGDqd;9evA zD5;X25}1X*WXD<5ZTR`r=0uO-Z0#rSh0S0*fY#f*7HScUmL<6x+B6m=Nn|COF^v-k z;GKd`C4G|O&lqHq|(V87Se(4I3}%Kk53&y53K&Cp9xLJLWJJiH937MzT*hT!78{)izAv zHd|^lugfz?z6MyoPw#i7W}2SFe&qx}qrO2CcuP{2$>Y*Jo&q^8o2c;cv>`v8+=`i8 znV0>g03zezS^26?@m4cD!2+epjAC$51+*C25g%e}00 zQ7(3?)ar1hp(l2SVv9R%c84B4PpeRYM0k*6aE(X6YX@?nI@Px73!`77*=#YFs7za5 z&x;NA1W?z?7OJ*LS%(}}nI+v5F2c1b063Y#q=^eG7KYESQp2*d_fKz4l*rL~)3v9} z_B<5B@|1y_ccYEoc|6t*z-cHz&tfum4ky4Cp7<8N~DN}NCEdE7yuzd&s zab?QIGJGquIu7Tmol0u96tR}MqtDN{iwbz(nP1&r{>IoKzcF9o^@~U~72idyTH?uQ zDJ879yIThb@{lmW@>56QDAS8W7q;@r&1`XLF>z>^{XiMHJ5 zCYMl6E=aWsd@;zQ>N^;}LYOdvr7_lOeMrA;4AWa3YC*z(RvUN4b>yWpWA;zA6G^v> zu72r4^ytnj&Z_!d|Jm&QT*I*;Q_rhR`8B;_of+DM4bA0CgwBS)O`n;xM$hC8Nd67% zHdLh&gc$sSTTLvWBX>#i{9F8U;_HscY2+#oA8p`TRnsa|)+EvNj%6m5-9+V5E|JKC z(A(YnX(!6+z}JmmBRR~aqxs3Tr-g)3FcH^t3Ey*oExC=?YXogk79~TpI`e1winvMd zlhyL(o!>$r<=KH34*GFdks5;DzaxpNI^r-CH`pw#?F|flO;a(RwfeLCu9Znvx62aP zz?GHKn(hy%>wcHM(B_BSSSa8RpJiP$0cvmL%nM7YIeCwzN~l{PLXJ$JDW!P;hlEpa zXno)@V3gv)xg^jXk>!rtwou|iTs6?=fd~4ir%M|c7khCa3dc?bw)8Y zB1q$y3wO$7lfb#MFRO9;-dtDeu_4=@>%xJRBH$})HByNM#5xN|T3F5*#BEbTu=eR6G7uuB&xe#=@Vm9Y^nON_<4&Z( z);I0`c|C+IdYPrjVAS$c#Gv?A-ck~rb`($M21M+n;jtfrt;<&(jLi}crsmJ}lUeC+ zSF9;vFOwc~Sr4US9b$h_b3b%eq@_e_}GdsDd^N<;$;aE^ZEE-LrVef81v>N*&j-SXY)qkndJKgL~@&5U`f zvj}P{$Pj%QIG%tX+p@-2oAP+AazLP4FAq-lRxq#ctfN{_a3iH5fqIt)+{BV`t|Ep@ z__2#KlPklEhiu|F4c{5%x=|ES`a&??bm=u*OM8jCk9R}4clZw~OLkC)r%+R+qC^+` z(*Cs(4m4f5vctJ6K`3El?yf2QFhTf3Lw<7?)#A`vi`WOh&MZx<6;x~9DWI}LSIj!7 zi68;wA@|qT&%F;scQ&s4HouG+9#4@z9yOCE2rJO+=}1FgPJgw5!xkvHt$jX0!>mUh zFHntWk*<9@Dj7IeXj)(Gzq&BdA3`;MFZDBRx~!|wHxzZH<3UNR(|$YXF%O9a$*_Ns zMC`tUtbfqLF?%$lOydBOEv8y&Tx(o`f(y}xe!9j4GNGFHD&!{YEWaw z)B1E~tlGOtjJZ^b4rYJ!pEg9n;P>3@BKxv#|G7d0&zC+3{EKWXzmaly{aVGniwFMw zq-a*Xm%Gt^E6^)dx}!^Dutj%U!44Ih7Q@H?p{H%079?iHwRZMrY9wYD?}+8OV7$v@ zs4<|>7iwgsC6zYDR~^HY3Ge{4o-;#JrRR%`VjMPOeZLlo)q0gi=7aq!HL z>M)l?TnL3O_K>#-T);#DRvE7FzuPwT4BVN#0`)VuivV1e_S^rDsILr&`g^`!L`7N! zq@_h#xRLU`B7&*v03e>cy1=N}5-K+-C{TA_UY4b`a< z%kDawX)F}yk(;Xi@+?Tnz8rP)svgRsX!x8lE*0TDxc5*1yOm@_OZRuS8MU z)#-Ts#;at}=~!C*yUsGH6G#-v!*<8?3w8d#DIb@e)ME&ez*br+^j)cjTJ+nqE4zbLgsuCk(IZH7WqlTiGf!pRWs!y)%f)ZSymKcg z$+<*&#=D9${d4rBFCdU1Vk#~_5Ja2^P2d{1ej-Ekgv|8g|ANg2+2n#hk8?Yh*wZ;7 z461knkZ$letzS^V{_-0528sT@J=h5Ex{=UPDe}PJ06D&RZnzozGV)zpiUdWFyxV>Q zQ$=Ip2U%6PtYS$>jZ~q5w3PBTpK!^SDkbdkxHrgRM#=P85&uCEd6;RY3}?H>aYGlS zh~3Iv&lOxI%&?9iF3J%pip(dXV%RFB?eBw0Lcs7%2rWZykw^-{*K3=efIOAEcn84j=?`}Wx<9?_O(PeS|q7$*_O_KS=!6nN@X;ZP9 zySG=s!D-{}jVOoKPGm|czN6#>?nN6)8b?v=F(~Rt%I3yTWyAYbRtXoQ%h!XGs$?!a zL0DdbPt%3skY`1Mw2GpmB{d7&`5@3%9_n~D0F)nJ1td(_&Rwa?MqAuEg9yzMoOs3y z^J-R7Qe^CbrZ_1NG^$5OG6@GQ#ctCbtn|lKb*=E8npxOt!-ig3B2zQk@W=Fk$VjP_ z*f#$ruQ0IRmH$2-|7zoE!g%}ScR?6PBs$1)Y!u7JkT^$Kp1Xx7@1@mWfOP8CY`yX> zB{{!>I!5+$Nykz-`_`SVzKsee$_3fSSuM- zFqZU3q5^B&%5drveF0;`u2zVcYq$JGXx)v}j3ouzi*k8dMI2I0!AKCA=C0n$1_Rg} z83djRo}mM~g7_qZrp0m<{7|Q}Gyi`0E1>@;yyxYgRq`L87JHP_KM39(&b9jjFDhA- zH&pQUh{b=lw?TKFZgX;wU)3sK!DyqGXRd1JQ@+*v`#y~AN;q#G_J&YbROGc@EOZVZ z(20KgF3u}lR<@xX=-W45tW)*6Ln_?TNUvmQyu@uAHv;4g(U}3uKQY6S1)M&BTVI;b zlR0!E=q*3C_wytq=Y9NsF%Gw-n2xL5S~CRVJ6KBMN$Zlm7aUB;=ehJ3lz-!mLF^tQ z)=3@0y)jJ?f%V=EEgHc0YId{?qn@gcQoIi_HLJ`Ts6%kMh^h?_R5q-jZ*p|^dQpAB z)VvvdDj;^}7XHl%g)R30Nr&{$S))K8B@RyV3Ju&-@JTxP;=HzaMsaIGksY&^?KXl- zIUOX5*Qx1_;BjQlJZX5vXI<9#6JZdcAv?c2r$AQK6# z(r4zCIOCiGlPP13RSGo4;*a_UA&@t`LR)@7P$2ZJuzi00Vw#E|c@IP8Q(N^!DT^58 z!DkfNC_|Z`2BYLzFot%NI(Fsd(V}fKoNL6bVw>E8x*S0kr|L4cd-=IzBhGf(C@Ns0 z+teHPum07|mVF`bHT4@I$a8vE*(Rqm-XDm0WNBve2eI$EB5(Q4qix}oY8H@ZDck~< z`XUd&r$zumhmzq02)4lRi7q08d~WN4f?nh@_{1;VeYK3Ku(tXTz>&;W!akW1V!Xa|EJi%F|e-J$X!o zOsdQX>F}uDs~k}SCkNK|e30kLmUNyibGbDLVz^h%E}im&{iNqXAG%katw9~@4IGf{ z0NpnCY`L8A%DHg?WG4U=OKI1*Z>=H=n@&lLfZatd>6;$Z;O6*woTm^h48N-frdaii z*LvQRvoytVD$?rReaMbaa$a*95uMV5XBd-ZaL`=M*_`$S+870MgG8v(mPu%n?wal(Hw2w(6cO>@$l^3gi~U9J|Gp329!&ioB4t z+T#MH{otlNw5z;NWt|GCH@UKIs7C~}zx`Gf*`lkcbKT=?MsoI&-c4cltfL7L&XKVa zK-7J;hxcc%LiZZ0>%3KmzcN{N6NR~=kW1_1Vt1V2P{neh47P}FsmAqap~0h z-`|SUmjToK1-Ml^ZG{L;6wELX9 zr5!;5@)tzx#r(Yv&B1qopreGyO1xBuhTA@f$a3Qvmyl#rsJBy^TKri-JFi#h`RPI< zQJo6{`LwP1c|I8dfBFTNiKs^+hb_4x?%LFQFF3Q}-C4$};()xCUla^c-*>M=|9a$n zKT!vJ6$cU#T?ud8*hADJHsO14{Of!QjqYABzMTmc76B^1qyplva**sN#Cd9MeXW}b zke@UzqM|_=dcQ7AZ|y;~r5BS>Pd()ey}@~h)y6|HlCS6BP04GK9Pet=iW+Z9U>qCp zm{hw`#E_d0iKDTxgM-ZL@xFn2ij~8-(Pewm-LV>HteQqasV%qQxD^2TU+QfJinmM}c z)Xk8Y36qC3sf@k`HwVwR z2Ojv66ZZm9A!C%WF!1cOYwpbX>?Q&{0*owMfdz zQrW09b4QjummlQ0uR6eHm|ErU8`xQ0ezTari4Dm-`><(w(NHj!^C5cL!?$)U55;t~ zeH9BL286m^7$nr`h9inTl_hk-`WW1|kIiZB(28d}?i#v|94$q2SmRvkMn47dJqK;YAn%%z&4w0^fva)ED(6L z2h@9BI{1=CT3ec{x8dWpfxaei6{<>pm~9^|@GW0$Ja~@VVSsr3xr+@qndY1AnCrsk z5nEync;X8Z(l-+{FZgygN-$2(Ze*i6;i04454Y=6s~K52pmKYpCJv@CBhM`-tn|{1 zGdn87H4D20G=6%4J%8~)-qWF_>@D|kRqS-{0X7@|UQl#-QEk0SqDR(rgAPBd8L*pC z9^3J@n$j%>(I0P$c#JPD@}r!(qZd)&>2P7AdHj8->qzjxBUM12pE298@!5ReHhCiO z6rvubJ?y!64RuKcVs3F2w6HvjH&88E#v}>NfP&}taKJQypYWUD9K671$`^%2a5C?HLV%34>kI;;~nP_xtd z5s5xl{S3mJf4^RemJ_ef`6(O|EWv@E6Et^HWyGT5Qu3{pVbeCOE2d04)4DJ$BxU%^ z&;nQ2c)k+9q$E=g_+y4CJ)3~(d<)rIQcLHlv28vS-wSD&N^-Un0vjQUO2vVdLLi}Q@)ErxpMsg(SIg{RcG z5HWa#;jq@Mr?-o!!LV>Ts>Tmp&N@j|ZXURn*AW!z+N?)C4QfFc)z2d*RwLWAEFD$DWiNu%>%cpQElbAvkCzAHUbO(>E_17x}k4rj_C=<34$#1iN_soTp>vQQ_lHC`yu!EKwnO%z9j}2-ix7?kqID zDH0*woB@3dcxO4y=;A-=Rs-_tH`~^+SEVs|2+17JS(KZMowa=UAU@=6@I9xq^0_NY z&N`*X-Xd}BWpGwpa%vxJRSludRmw6E6wobts^;PibfwKrp{X%2$qlmG#j9s}@Dv0+ zrjOgj6F_~KFfsWQuoyzUNt1^I@Kfw$8vC5SHLs=19_Y2v#`EWxLdj3XRFJe;YyYCU zy!rX?>n_^klewBS#ikEXOVHu5ump87uj(5)a8nqu@{`IFcOrR!1eEG<9Bu+K(wXLz zJX$UjOg2UQM6*-32A=chm6ksa|M`N+B9&T7m*SKI^LxhFQ!*2&jp1Q4g?1%(c1j3i zd1F>C=>+zBT_lbEtTa1~WCvu@4m{MQ9@ zUifM%s~j2iG-W4#AiVajZQ)P+aMWLt^zK{?Qr*dmef4cK9W2i0B_boD$o}|+ z6SBc6T^#bVCdV(#=eV%%#E4d7ZvGr|y~%yeRy;s#D*YGwKZ{=s5|i|`j+ zZ{_z)qxHEtK(sH0e;B@}#4VCmHeHmjn;%~#!?{BoT3Ou9$W|VGy##jPyISt`C*wI( zg|n!LnYPet)B=?N-h4gmpqeBU$a6smFMMIkm~?Y^0y1*(@pIIhmxC{FO(5?Hs~!OWiWyeYUKRir$&^V(^DSoA zBqnO;bzM*BvL>G_q>HC9kNF|bee&c17EWEQn&pEDT0H^FBn)19?9debqO%(tKTT^U zW{wAC!*%@^TrE7CJ_?0UX-wER;lf9aEMkuV)agnX)vzlnrl$aad6P+%OxG$$Fs(?S zSY>WJO(+gzyrx!_%7b->3xkZiIxl!KSTpwp*21M5Gj0RXXXwii> z&kaN!zX7=uLHv|8ydRq!cq4Eq7Hjz*x~J5Fpk)Cx1n67Vu__{=F=L0V?o4HN_3y`?MuyzgwD_R?OMqXt zx)yN_J-sIjdS7B_I1%*)W{REVAV)Ismdz9oK(0Jz2DAZifq_xc-Cw7Y3rZYRkqd`> z3M9|~L2)(tF}=DVa?#Xnw&=-JFYQeZ?t!`zi5Dk#w_4VtbQ*|Eu7%Dwv}oVpXYr%u zR6<(1&M$@OjXpie-KJPbL&RE>9v(G52Bp^(g9MXpn6Gd)z<{BZRfA<9N%14eX?>il zC|rYFwM)q#YmG8eYP<`gXm2-p9SBXf}-J!Ke`UFmcC0?(CHqC&C^yLTtR2wZ$T* zrV18ElNkh(E#P#iD%<7;A}W}{;tSZ34-M!5l0$xs@ihSAQK3IMFB&BF`Nu%R+XMPP z;@1t+$gT&3MW$J%HkzE$0Q(O?cB}K!DGuqi)HLNLZDVkOg{v$_MB zr)5rnfjr3VM~t}v>#`1F-)+rW=?h*fad)V$r63xtjY5lo)uB79xh zngIJn;pum$0u`l4+_1(^KpVd=;C+GTyCMx)!AZhhNg$!<5 z*W1$@#u)AGywH=Y@?47r6_7!tlf{fM=S54Zx%AHH@BA^+n-|_LgEu^fP+~uYon?ZV z8M3J9Kcl~((lyY*0)2tP6eT(oKv)PQ#=NX9mDOAi-1xEg&@&lFCV26%-CL*$}=KXZoV>hfEg zPkWwI&7wN}#3Ka>z-hgayU)+v2(8`Fr3(1miLMmu3!mpnQDDb~1?*Wh%bV}oa1%;_(FFZhl zq`3)i?%h!5gMnSq7Chw(astf%Te{*bY4L+(Dk|>roX@~J+yPOIt!u^1zds`4-jU|O zU)PO?&uvfQ=Y{`o)r;K$VLn^j(aPT!bogHm7kVp}Jetn=X1guKX?=8bqe_iHqp`oanQBUf9=YLDk`*bn$3ivJOYE z*3J*IXude$56Z0#?0Gf*`bp>VE-QYH-W>WL8>SBT+ZW7JR9#flVT#-w9DL;SP#>Ap zSX?NwL>chVz``WHHs%$ESpFf3p3D>m;o;x;zK!OK@pG6Ss~~ka7_|P_t-Z(#av8l$ zH{_XCu?&8!l8CC1QXHo%SG5A8Kq1e$`YBZM7m2Y|v(!@yo0bkgK)1OPjTPHyTcP`ERTa|L9)*PRdW1RL7$v zW|UQzDvlNBcSIZo;S_I@f&Pg}mRkDdAVdT8@>XH@-xe=R@B| z;)KM$udvo=e-|@k2!i%&l+(&plJqe0FqX;L!jD3JFS8H-Y?XX67EHVY#V;Jc&@e{j zxPYNR7*2i+4AY$#j&1(LXwM1B8#x<@&q4qB+^%)-d7Ql2!9&B2de7@MYU!&9Ox6^` z$xaMN9uK)aS<~3u0+acKUS^?ma@Iruj>@}jB!;1;qW^LNitXy3OXH`;K&~=r2z74C z>iUo1dyPJ8^%gv>;a~b;Nn!DT{Em$3iWhimBU8pC42QkjAVQxU9vS)bd64QgG1ByX zlHaL-p4hei{sb{e;TtseQUqsg5`A! zTq>Yn#i68*(5TTHXm{peJxhJ^KhqBBJWQwgy&63rl`|JZ(fTA;A<|YYzsV8Q;b#RZ z;)=(1-lyOURJ?yxAzRta@c8OIU@}9NcMd)o;}-7#D)SI~#Q>A{Z11qcOt@5PU$fza z!w5Lwewu@~wahoBCwYx;tZs%*F0QB4xMT0oM~l_yU%@CYD|;#WY?)N8YzHce;?EH; zQ^_E&&DVY)(2@BWoFi9;ghD`qA%02?80$v#?S-aBRyb(q$OUAAcF*)9)g%X;z&!^( zrcxogG{N2S=?t(MI{VJmkEXZ&$pm1NzGT6U*ah|?y zr}|9Jt4>SP6dQAddRapk=GD5am$7}EAu6oC* z*H5;cV%YibLh#F5=Zxq!Z9$brMrybQcx+X&4rLHpz*BE)@Dm}F?EEg?!C9g}+VLG) z{FG;be{S1t^8K1xICQnhgEhUU+Aa^ce}CH zP=dXbRHzzD$f^`1(_4rcneP86_Jwi6C3RNYZYLZ1qq5ulhf{u6ZOm#h5rmQs)GWpj zj;UIy7??>p;nssJU;{cY6nsqbp5z?X9B+R9k#b(xrZ+ZZ^hd#OuQp*8_dWpz=yc?% zK`_9*Cp1%rhb#|MORnHdJ= z>Ar3SomfKmSYq%=!0L?iwxh*ZDb!jmvzFjM6AKb?=L~UBE94z>Kd8v|D<~vaCvi*u z+y)-Ys*_bv?z4QYXX7UD=XQi2jsMLN-CyowC+-> zU=S^Omz==u-Hym}Mi%*&24AY~0Fj>1ETEPQ7Jn`3enw8OWw7skdBV@BH%yBD@Igb) zCoxKlzzm;^2X`K^b;dmYlqTBt=`4ukQG;7UU=W7?GsY(m9-?H+suVoDlb`5|uPQ_1 zR9-j5<0#T3%JY%7F44pVx-PhS6L0a>cTsi#=c&bcBnzfGAIR`Z2Oy^so-2Z9^zlR8iymu+M1^qz z`3%Vok8`_CwsSx4kY6>>Nc}p_h+Q1g4-23FO{pg*IwNt9u?*-(L|z`qBBTqgUHY`; zp9pfv&-e6f3tVy&aIsSsu~BDCms$UsV4g#b~bPah_%4c+)#m9>X6@XvwXVv ziX6VA*}qYW{MFNQq8LS3N*#cao3ddpR^(jwgM!{|sclNuS(s{DyDhD;2V;bbw2^f}HO}1&_ z6A`2G>_9|u!(P;{G`mSWVVq8}V`uF1jEFNtxSQ*QwZ>GFh+8Q+e(3UO=fv8oa@{PH zl$~Y-h{>p`Zp~dWzfsztOqWzqAEn={MI5Dbt_0P2lRd}09T(|f1Aza1E9AO82Ya8P z#{BX!a5+<3%zL z$~}(6;Wz|@U1Lc^CW!r;% z{lwa^AEEgtL!aGW92g@;4DKmC7NhF(X>UYuhFkLFey}*m(VUsPoS5nb`b>1j4Rxs~ z%(&#Vv@R3?`4hT>$KPaIs7bdP*nxN1d*%l2!VWbMN}T1gVy^$EvFX=J<1P&IClfI5 zRb+F*B!879&rgx_O^GFn%yB1gkhnJKKyKrKs>Ccd5xIbW;0kZ?GcbEBG53{O#IQzc z+v8jt>u5ex#F#4^59jO}yMWv0&(`dy9;gKKZU>EE;W8qBKOAET=g-*8ENwk27~c}w zP*B`3A=c!zGy!W))rjIc*$e^_@WS}1{oc7Zb$xeo*%8Mp<0EIb<P}+TNLcrbNmP`Z&wrU0a838(Rm(aa7peI~UBJc@6 zJkq6?4?;u)6#7=G--)m*XZ6JEiEm&HqxhBin^$w2l>N#tzUM!cU|*OO>1jnEJ$c8U zJzU&1xN$V%+t)ifT1crPx;%O(eWFT3LXpLqRg_`;@Oz{r@#Q5aJ$cEY!aeVEc)5>H zE!^B#2)H6B-HqRZUp;pqc4^<3e0_hu{qD#YaksQq zjVI(b)f!+B5=R>r(7~dE)*@ok#qW-i@^QcdSw|uDPXr`NtQc$tH8Lb6B;+PQ_wo54 z)!}RnP+7*2@%^7rD15)5~0*6eNw7^_g*eSS6<*UZlZ!|5{0RrtVzP6paO zwdAG0Xn`|ddW08>17?&QrfS9nn*7=pr;jkOW6P?PCm)XJHY{^B{eBY8peOpnhU=TC zEz%&E69zt3g&1mf6Y?TuYhKx_EWKnjpS77ua>DM_&x}J!J}LHYm|HB(uaLSwdH-TY zs#Z^Ubv`7fSJDgUuu#mBrndPWPfRo0UHtZH_`7P3P)m6}&G4Mzh04c@oc;dyd--jN zAIuyrI{NZq)cj?b)W9${&okq%SYd%aN)O1>nBu1}fjf9@`x_7Vc3_dOy=k(MQH!0-`JbxHcflNgoCu~hFK6~r;pkx=_n(98KyQ_w*1LGdw%JPz)XG__+&~8+ zD>nJ7pnw_*3gjd~(FQ68_@j}n-N#G*9VQCvRWkPP66cV<}2f1n#=R1zv&j4=Na{0@6C2mURT z@LzyvnX`h5J|L$~twCf1LxHy@y!xMV75`5oz>b-PeD+sN66SzmMV362^m5+7f-fw= zU?64~^t%#pvRp?N*;~Ot1cBcmM{J3Y{02{r{`CT&yTtGjgb4AUwPK-q$Um*_6n5?< zpGC2Zsc2Z4Vz%FkeGi=IrgBkl;-)PrzfU%qH77BF#?S%}^n*H(EQ*}HP)?Mf3!Smn ztK)CmhZbQ*=e=Obw^_1OX4@W}IIR%C`BpuZsG&X>y=nq(-Y&wxM#hM zgB?C7|BOZ(q#+Z%po$E*w&^BwE+?3|4C$O-J+_ zqF9-7xUXkRWaXzteyobkjBh&_;yMl09GN4PjUBBS2CGxWx<4(@81dJ$a97mZiGY+u z73DEO&i3^QPpG*q@g3W8&vhDIdTK>mi^+v~DCp^!O{n7n+O3_MF71SWyf@h@To*cU z_I`%u;!vdnSfLiVy1$=;g3d{8X#j0179B8LXxAPx8zARhs$sR@G%aInM&vQE&3_|>1uz2WTo(aDA&Tep}Jdg05g1QQF4ro~;IUhPno34--ZEW>sg+ z{$g*a?H9??&M=;>#!>rLA7melPVEn8X_ftboa2xBAyL>hz~NR$$zWf8lk~5Sn||V% z=Tb!D<$i$wij!>EgB>>;_K7MM+vynp=%db0q1NQ9S_vj#>PgANjss<%lXSvY&JDc970Y9^12*4Ja5P^V^Om)RLKetwa|8`dam#3VqhxlFL#sJa_qYoP-XaM2H zy3NuftIKPieZlo_UY8rBOf))hxJr@1H$i2`v;8n0M~_U^N@en9`=7#5f)VG?fd3@qutUEx&wqDEym5KbV! zYi{oFi3KyBjLgMu#ynD0oY9>yxm2ObD=X~j+^lQ@XETq3K36iHYmBGIy_j){cX9H^ zAT&xG&JejLhf5ArO?#~$bJsUoIQmHVnf8hZ$6s!W|J~XUu4Z~TH}$ogmz&HYnBT|r zxJtLX!Bu`PA2{HNuS=O9UR-M@o+W!rc-#E_E#f+c%le&(%G;`B4{Va34!(ZdB0)0qYR4ODGqshVa!MtQ1K3;q1W8qojd?j zh-Ka)CF1TghiNR)>KyHTg1NkywVySf)|;LOp_*OQG~;CCrLis$qU^Z|B95 z6a66<>SXilHOB~+%sOtXT_KR_Y;v6aO0{uBm<61gwaQ9*w@@*>id{yD9y$jGWAnlO zp5sJyikiZY1-J?y@E``3c4zzkIFZI}hD-Q~Q_H~lPEIv9{&T+b+S_1Eku6{e26}h2 znoa-BSVrEls0v9sjitL}0`Hc3@qjPty5GSH@Cioq-SD)Ho<<%;a&IfAjfbja@T`6e zfLkyEfYuksZ)hR_dtuL`H5UB&->fOE>=L+|RsLd_y4XzH-O4*X$q0MYyLi_<`Lh-J zRZ#*)LOC9oFepKbdt60w&31rFT}2T95*ZP0bCkrya$XT8DB_sWY>61*IFc_;G%0+( zck0rUg!TAIc|ft5Hdi>R&MP`bGy>o?&g48`h1P`F6wOW)uRliNKcnI0%+Tve%)shS zgrsPz_kBL%shHkt4~buv?(h3O2#lg`n@y}MshLnxr7Vu`QHOng3?Ch`q55`K`$|mq zo)^hCR#=1w6k3gnyet2%5}d7om9*-ivBm~j%}QjDZPuUAAEr{Gx~!Mf-_?GeC38QN z@$DbVPRdTFk$Lpx*tRfmN95M;_HKc#Su)ns(sGp#g;%Q!R1Qb%z3)>+)W$e@xoYr| z5Vbn^OWAS&VAoO=B4Q{%x=8XIc(uq&m*~E=t&Kg+nF-!3KJUXj3FUlJOwMLxqXA8+ z-b930yUG=PcBAZ!MPFK~5>TwN%+#gfupen4n0?0zBqu4?&SHUELJhQ*qeBmK)+Xa? zEjYDA8huMe8bW@AE>`f1_R@j~CWFTHbqW=jM^GK1=NG&`w69fB-CUY5HjuFcSVRo} z-nD%i*bpHo7?JzC9yu$7*0MrL3VDEd(6A%@4I+N$7AoF>(g(op(#W!+Fp76XToTp+ zt6nu(*m1K$RnJ_h5BBrh#ll z;U1bs#K)G)&7#lz9?kCigkv9Y4UZzZ^gez6L9F&N4=kklQPrEe0rI~iJ5C-KE%y}k zifeEf6!Z{F<(|C~{;P^keeB*Px&sJr(YRm=onJ>i7SmHBgj%7bBKc9cG$1Eq5!zwn?Z^n)VETTU4o^xh$%5JYQdG6qg)gfv^Rq7`JTaiE6*AE%4}}{ z@QKErOm*BzlJxviyOyS@6RQ-hR_}MUht;nV^XLYH&==nc5tB zhMMb#Oqk-qTpFR<0d+POP3l~nO@sNe$GHv(L_mi8I~|#6IdQxG zg-UWIQX-f7yh!k57JkWtm=4lBW0@Rmjz4SY%4y#T9g}<6dh3Pf%W>EKT*p9)GAWi- zMb`p0*@n}c%)rsNDFt7*t!E;p>)D*w_lPa|_gn7u>Si*$WMGb03Xy^m$MAAzC(}RC zIEoYs(?`XMB4~3O^E20F>m>*P0&Lb$Z*+2M=?jn*(cs?69{(Xi^n>r8 z0m(-J7z`3h{hD~;yMSZ>SI;Etf9Atm;XTIuMjW^vlfSY*y*Mq^L5NR&=fMEG%Rt7i zasbq~<&tO}k+-b1j8lC5VPtCtl2MXS$$FUt6SllLG)w%hy)@Jqi}Jd5 z+(qiW>CTcJU&MvYe*P7k;Un6k(dt(|x zk@`R*1sz4k@q63ZcCZ{Db4y@L@@bkA+(*lCS`IsN8XhN`rbR{8h@Qu^|I2sy$3`Il3DG-T&UH;Y)MRY=wLz05umVyZin8lL3YUI0DNGaVjhl#oa)H z1NMD(TK|1c6~kJu`@+h{nr+l9%uJ!TmuF3}jz^Y-=5j8LlNrn$jb4|A zm#4j`letb>z5_X4T`Zdek?8Kj|f+p&i2t$$o02>Dh6)H=hGXL5@dFS{@S z(?QjNE-7HKudF!{j?PG{7>rIL4eM-%zo`3tL(%%pP6e7t&nd82Q)S* z0@sssRvv%_-F$583k+}0CkUXWmbrb|FGFleE;j1DC|LG*q_N~zWtPb+4yovvx?g{bfoHg&#B38X-ryG zbnGSCyo7exc-UB-$2#UBHvOXcMOkEK27gGE`A&y}n~FSfe$ndf5pjuIi!{k7yYK(0*Cz=`;Hvxqe8K3R77!3&fI^t{xpqM|tWV{w4 z%=T)rxDTcO2I3egPDS=?)TOrvU2W!^Yxwy#>E9hALWiF1{T(IQP#L?(N~4~N%6f(^ z6IV4wwFJT3($%Y`)Subj-oaCr&iqb$&3HJeduu>Of|&QRPGdQMZTFWr(9(zb#WaJL zz7AQSj7AoZWwIYScIPd00j>7md;r(3WxmGt-fJQOnUrm7sPdgm^xJnOeQD-!+FFY z1SSb|iK*#C0UuYu$rO3V!j|}rqS|Rc_uiP)myxP)OFndYfWKb|B(%%&_<3q8-s_Y2 zrPG7NDyr{6s`1l`RaExchpMr@&-1z_%O7{1GeApgbd}Fwk6iQz8bqF#+x|Q=xaM@K zTE}SNe0bog=_Aq0-y*Y@LsuI{@ps?UQgqey;kmYj_sc=SYN}{u99@?NX0>=y(s`Qt zJXOmadq!RU?uZ9}ZA!m2$(&+_A%&jAwGzwU^2)5Hz58o+BZY;|W{rpByVDpUlIW2EIwUoPwZ-UP`F5tawtKX<5SPu39!2?xcz^^5o^ zQJ-`q!z>d320>CH*N6oTB`qWQAPfuBcic%}=WQ5ef#F2XJ+}7*+^SFg-F||=p$qS> zz(pf0BuI@yg4EZBMkO!xsqERlZRJ>3j4V|!1k))sFzLqeStC%lWyygdc1UpYPsY9& z99JPWx6c4B7<4KtC0uFupL|IK?2`4K)(F1i=EA}fys05ZC10gc&~0E(omN*0j&o_< zBGsS7l`8AVYXX~9!`-46=jaIfc zY#vt#6RK5Kr3@$-cJ7XhKdhc;XMv+xCEm~r=**8sJ3n=EM9sZgRqXe)Lm6P68nr>c za=#>=?HHc$R}UqiT`uy!0Ptl5hf3p^Tp&<9bL_`G1`m0ypKc=@$8K+FdE?r zV#Q~je0AWw+~HY&UoGKY(z$X6OzT)(LF9;1nJzhaP968R z@ZzPN4RjG=VOctC>pfCbyanx_UjcGkh|^M?)7GcE$waHhXWZpG^zuA`BF#%Q^?SnF z569)M&ff{krz>mWd(x$cvFtmdm$a-hH$jq(M$gKCojGsHBo26XG1X8XbU0&@nw8|Q z*LPvoxVP(axl{YGI)8Yp!-9*L^89S_Y12EUYtqxA!`$It|fq2$vu-~%v5kle?T z2Nlx2>LcB2bU8afPbj;`O4i*#jCcIrAHl7c!U5Co!oE+DJz{wiSe#KeVYX`zRxA+10q_}M2?uWOlaiNO6tIqeUnF3n8j0z&iM2ex**tDC#ly$UL*n;ZPT^8}FVqTxM z$r|fvlK#~C2-odt)|zSZ+^2(yh<=f;;s}j9-#X8Xt+t+|1d2|c{|48uEjdCcuBOz;<8herE#UXANLge=GH>cs3#p%sN+Gn#Mejz^B03doiY7fD`}9FS@J4K zbN_%6pX;6&r#s0c6LN2{2oPjmH~)qFwyZMak>*VBSn06X_*tXou_V3fSL)zWT(FtC z43{$?)^A30Bd{*sb+(47@OGItjOq(dMj z#c;u%%X>MOFSGOQS#P>2(G*9lhodKIs_T0y7X_mcrM9t>-G$sF_W_Z>aEe(ppFY@f z!m-PF)A5JlghQ|_Rvu6{Vc<`Z&u}kuPU)N1ZisiJ&~BzMEAFuui0qFquS&5@(e3`? z{k5mBv>58?X`y9wX!}RL)^%A#V_C|hW~+Nqqoxy}TR3VW5{0%8qB!hmywTiv_%R8~ zgY{p;azC28!b*Dv@?@8<@xu3Z9svk1b3e#a>EDDHY8{ics8?nWR(?w{f(Ik7Or(GA zeLX?>eLTpY<>Pu_1bJSS*HLozcar%QI$Ci z{nN(B1@Et-2ScTBzPE)`F!~=p^DI{H*8&5wA8iV8(!WfgDq8t@`Z!mD&Ay^<$xcsZ z)keSb8v_C7K`3{Kw}<^iz%KPFx-lq%S1-J0IZ>?Y`(4GbJA1rk!Rocb5<9U|zfvF^ z(-lZ6D&%`ZQqJ3zw>sGr+Toz+b|*C2unavd;ILZW2 zzH33?#)E!1CSkbWXkoTFcQ19oHHz?XMK4jNL$G+NsO87lJHdjjphw09B}A@gqX!)} z{ThY=m<|ZP6qcE4f4Q-h+kPn5g)hfy7}x|C0PKNRrCuU{KPsnuO6yWWk( zA$9B6aU}nTv8<3zU{*?ZoLZjh&*@ggVPfSGX%%QZsO;F#NO@mbTs`(;8v26IEx=}6 z;?quy6mqr+ejP@_hV2+xud37xS6DUg2MSK&)|u$@5=^qSBI`7s;N=E=L9cxlLe@XA zgbHCVO+J{s@wevWNUbdhD+e}*dc*^}IADD$;!`&u6d_zokMe<0B(g_MH2r>GLonb! z1-qd~Vuu*43PO{6B5IoB2b*Y5>=u$v_!XWFH&z#ro)7gf@o%Q^>z|dbGEaW%adK6g zvnK1~`2qZDA=HT9!;RGe6uj@MGHUTKwE3N%L9(Yy1YQJXPyp-+w(8zu>Yf+MhDn(L zUw7|QG`9edhYzD?zn~u!{V!CN{e`2F&hCJjymzKJRi1cr1Gr!8KgW)6Y-RvZ;H_iP zhI|qkDZapG{MhufKF07_Z!%=&bjx}Y@XN-T@5dG9u)7CexL2zzjV8NE&5&Z5 z7>qv!qa-A<)W7nh5AU>Dv`ds8rEkzFg%~bA&=X@TY;(5nciic>EaX{#Zhad1v_4yi z;m(WsLI;Oly@X_|9$^TeXck;;__ai9d{40Rm4w$2r(6B0{Q`kkh-lOoW72jiM{L@= z?k1;w;H-B1URGlSG-1h{UVq6ov~WxNZtc`ZRcsOTfH$gYf2+xKf}V

=dAY=fdKr*z#sTZZ-n_^5C-NUDT;6}TM1fBWTk@YXMxLP7Dw)QTqg zKe2vCf{kB4I7u#+gi`<(Xb!H@Q(cu^k-xe;j<6BL@pP62iB^+OT%60E`$DxAzqi2l z8-LuL`^_FWzsvKD$~w=sN0DR71h%vj`&U+0LqeyU{?7LFSe}nwVSW{|6|X}Cnk0%J zI}cW7UCdss6in`P{<_$&p@<#xc^!z(1l24{DfQ{RMsk}f7`72$+R?*TWF6JM z{SV>sbJ)kAPXzo0j8=oa>%KuMjOH)U(-(s*$iRg2{sB298VrfZ17)6!(YgzxjQaLJ zk{N(S|DXc)$@l2$&ISe8LlmYjj`!|AirfETcoo81j!I8#;g=eZ-*QX^kf>fk}+l&`&(nL`1G= zoK~FKO7bOe!ShksZtxe7OZf#R5Oj6&U8T+IktFCgGI83TwGCHEDs6A8=^so!wn?nY zQ460dhbrsISZb zGTi62yeV*&?C-Rx| ze?BBfDgG2O9;KJO8=N!=MT{q|kj|X2f=8o9Y3=F?pk#DU9<`RP8sUM;n1G!3(-li= zUOUt*Hf|rtvPP8HC7nUY$M5E+qC$YG6KqR-+(v}ctG6KLWGwK0Pa(HeHL8(6o+ZC-XK+T|76gU-2ztl$T;SB}j!w?z_}w}H!l!HZWxSlFFE?3{IkU@jM! z-_DEg`9%$1679cS%8Ggk0b+gU<1AZY)7J7XgMCK0LBIlkHKCm)pf!cCO2L>R!gs+q z$ga4BKXp(XloE7c{Xw_Y6q84!<_4n`m4mYPl;GUE$#jh+A~BU6M6YYEgodHDLutXE zk+JCi@$}VURcGJVAPP!~ARS6b3wY^NX%JA55($ZmAa&^ugAVELmhLWTxir$~Sy*|(UG0!*-^Ev12z4qE`ukA*nw(@^k03$WWd=pOXKBW1xb;(ee((zrU>3l#Q z>4zX)IiDK%vaoIc#_Vqya&^FPtJjdEc847wFDV9HZ@(rn-#EYc=HGpOz=E{d>=xxp zZSB2K*}{C*mE-u|m;4(cWGOq-&Cz4U?N@1mP@RokYfg4g;Jh_t*)A6tr!UL+)#bI3NJSmPMJ}+-5PbUwO!q{1SHJeX-vfVk2l-z!`(Ep^01cK1 zs7TSz-xEuDqCG;z%bnK}ppDlzOK=|)?jWR2WFjR)UkNk4j6&lVcG+DJEds4>dF2;L zm$drx;GX&kFv9-6>%s@y;|^1Dc91WPLGhp>f{L3gCZa#HjX4g-npL%N5)D4Y2<|#X ze6;2KmA_V$uWYibiBVVi97Av2z1+bK_3FLv=Opqp7KXg4O8#cfA|s`iNY(iJ<(Q>& zm_7$5*-Ttny0t>0OEeSNsOY2zy{T}}ZnF{0o<8Mb`}Ivzi7lzy`Q?EtZ1;gH5qYNn zxx*!;$P(@5XelacZiC99`5EUr(dqkoq)4L^QA(XBx-vVfe;}3I6T{Gr=H+a6*OM)G z4?omWA?G)YN*$cL2>u`VgcE50cga9#eEnyZ6D=8snqj^cX#%7+I7t~CbQgszfS3~C z`d|K5<#lk1SoFM~M9;j81@4@q^#+-Wkk35d(Y22r;5dm%`Prgy#F{|9@3EryfEWfX z-lAGP#H5X{n8TlTRdc|~9Qmsw`75s(r%45NHCHlim`o#Fr<+N(w^X%UgKjLM1cuDs z#h`+*k@q5lXb0MS3)dP9oAc-s(y><*v7iras7vRw8x2YvuVc`!w$NUApAt}e8fSbp z(90&*MeW&hoI;Pe-%LyKn(hVFvUZ-;;d?iOoN{oh?LZy7*wsUO5ZLaxX=S0;{aHdH zpjfmq2=!Mm-Xz-|$>dgq?`f9F1-=zQZ74wm=tD7BojjmU&5xVh8YDk#vGP_C6s#1b z^-JIA`mf#Dn(KI0?qv$p6vB?p0>HImbAQzm5GI}8V2ZbH0{!3CY(HZ9z^kG`@;lpV z(Uwhkj*V+E&mFt_j@Z0}2dN^A;<_r_?|cd4zRFa6CNk|ZRB@l{@NC4@$*5W8wlhFB z8pVT1yrU{ak46h@iT+xQS*2raVXlW5jV|m^+gfGPheDXtyWstEhtZ>?)^8yXs9ztd&OE%nE5LxN#l=uC{C&6@eyBmiX9UFC zc+DKGs)#wndyS)-S|Q#o;kK(mW36KMdal!fMb6?DyI=rR>t0pzi4A&VA)MgHr`uJ~ zgl~KnGR{Q9!f5lOjy5;LrY;`A-_!^@;2vh!=zZo=Lhd!LJ_cKC3)IvZpIJ0avj42t zbbZrGHuxr-vx9%XF25LXwKm1Hf3J987~+aEFZI6E=bG-W5$k!_u-u;POyASlV!l|n zZ9Ds1X#>}H`^km+Zkv9n(t@fhP{O1e3p58G5|@@zq6<%vUz|TU`yZ1T1iu0^2CpGUDZvfym)-zjA5d_^IwyTRA)q&?lw6_` z3&sULZK|TQERdZltsD#u)ONBCITW7Rinh!xouK7D^Xw#4?aSCCvl6&@)Jd$5&tYX_ zaYv=1fxP*oN=>^%aXMOPx1e2aRVj`&c7(p5Pkh^63uwgV0h{b{3Ee$MeaAA}+4N^2 z^6*wTSHbEJFJap;5`<8hWshH@YqYUl(6c|E(}R>H2drxDRvqL(GS~MAxI!Ma88Yh6U99yKs8k* zu{-G;#i?`g|3$&dcf^YKLFUZF%PP@V1)l``(n%GutRvN7$S~O%vWi>ykintFf7rk2 zTR*uc0nm(b<8oAF_mo|i7^q=ZVhF3@8>EEuk5M6zv+?D_p21PJ5V^27&;5gI+Z(7T zmFo^-wT|y2lrcuUj0Lt$;}R#{^!npeYKvJ-tyxb;3-4+!R2Y_yjD==FW^l6WHg$Fx zbQ6G(S9FRQ_CcY!fLagrQ#G>V7l zV7I+5QQr!!d(BxK+ip)2~xP_#BsmHrv+6M10`mOcM8+9 z{pI%C!N4iLGv}P5xO^JD-;OETN;KbKceVS&Z9>t zp;g_W;9<)m^z_MF8dR&%k2r`NIOJD+wgql4clP^GaXV1Z9Va zS5d?g3(3%&FZi`WiczA?-a);AE%KZUEN|Vka@hmGmo_{wwBMi-JWK;0c9720S>q$g z^`FvKlz`?y1qf)e;WHG=_Qrke=^#p%6pK2lIC zy6zD{;6bGGMU2lE-re*dPx|-o`w+cTQ08-@ZHhX7oDz_HS6|gIzL!=Tt{Nz;Yj+a0 zeCk%+pr3KJHhg?r3g;zd0~=Av`kB&moO5?xC{<}p&2)KVty?VYAlHu$5372UB^}1e zu};!7obxYQ*d+daS_mewD|kx!7T~fPfw?O>J3oZ}4T3awQfeg*t{rUX`x zBlOg8p%;ZF%^!E6G>zzcIRB$FwRW1pd^l~*B!To5w6g~UgD~srT=?EyAD<99#uhMA z`#K~tLLc}mh0}tJZd*`$D}#bNGbp%o4^^fnIsfg(vn>`gxULwWRZ9wr_#4*oMKz4` zxLYqc{TycNdG}0a_WHp$7JM9TZFF`Fl5ZRs>KF-LnEKPuu~?2m--f6?~J*E|6>GtRAAeEUMX)JnG_D zq&(AJ)1HFaT4XIxel$3~+A$KxzY6{|EHsP9I|Nq1`bp3cIKU@}UHMfdQ)ZQPPZi{n#S zuXj$vSV<5F@s#g%Jz*RG0zo&X|6(t*r{mcG827sm-&t<~6%Dx1%s(FGeDhYf@bQ7X;(j^LYXCDhKGt!!`Z{d_j`A36wJOf(FI2*Lyn&CL>!0@hZ4UHWT1 z(TgAN3%vSW=hW_{&U+^PIERL4$o`crM6V?h}e7PAP6`LCW2%7bhZRo7s z!p@UyU4O_i&@t|aEKq&^%fS?G3q`3^bvQznSwI@hOBt~lVnheKZWhY_t3Y2})S`@5 z9{y;7BklleC11bDhHz#WSPho}M4sRmalUADvHL?!3LzOWt~1qjbAdx7in*h3MjpGv z5chO`Xxf-rZlKjh2C0DE3{Sk@?B`~i6xVwXrDgcZnxa&WAy-G21X<7`ns=gmW%TNB z3UPzQ1dX6rjY^w|nE+-{alWyr6!lE{!8WvHA?Izc9=>s$Yl`?1GRQQGyDs*pAtLj%qKY!6#I^J8YY}c)6 zJ108ZjXU5rYrtZ2fB5&9VRsL2X~6HM$HL!gILQUg(*2qm5noy*(WNBaIUrU>i%}iq zyZ1(~CE}om+ytc;iChc{zUkD9h=CO-TG^&dEGBDh3!Jt7c%#=S*!3W5P)ESLB_zS+ znTp~qAzh$fWWgeouLrdwQ^TwAHCA}|7_|TkP%)i50yz9@YSxojNRoydg2dopbVY%S zdd84ttAt65G+Sb?ZupH~=Lfn+>cC>T7P4fvBp=ax-0KsB(PW0Qmy4G&L+T8ScIkJ=#wL_W=fj}U? zzS^S!fRYewM=2+h8d4|w5!bQ5Ib{w7=(AP80yXGl?*Hl!9m-F(d6_U&Fi)@g7tBkn zq&mBDGlanbP;M%k6HYsPpr%z^j$W~UKlmL(+}-#wXzR_VEfee{ub9wIhL+{7X8o=n z9xv-Y>lLU5fs1d)Fu#WF!v041lK1i0=$cqyKIO#>z16{phWb$C^TIr_&4v;D9mSLY zYWbPIBKGVHRl*+E>1aM`6KJ{D(d!>!;f~lpFBqgf^0A;m!`fwu2|O6XWFA2sIIX4s zjUB0Qp%n67NC_+!H0`brv?ypOKeh$x7$XL*|0Gpm22tz^v*@Eg`os7Wg?*J0?ZMc# z3K@Q_vxiiMn#ldcheCmoH2iFNV60caP7DTcX1F%JWdba1aUMlQ$_E0~fX%KeS;R9b z+yo=Aqz89eS)_u(oYl>(p0rOln(f4_naa`#^-27_mn}6KE72KzITcfzB zQ#^!<`g8-4ag#<1z$zfq2oIy~AJs@5?r;|9m4iAEV}L8I&A>USgj0Bh=uqcdgH+;$ zN@=C>4n0GZ+z4QP5*jJ979leuxSTrpilLbrP_|`%{`fSfja`KDlO9W!cssQfpV>x0vE7 zPvC7%JS>ZWr^mpT#;LPok%3{8BbFjGZOv5KWbk{@7`!h7qMK-^4`K0Vm!3gmtBMEF z(^c}hGkN=*%*_tAr}BxXTkpoqrsXk>|93pD(h!g(dX9wDWh1TnMH^10rb8MQWW!R! zY|I*%wl`&x&RQ&xdBH>(8nrAij0Xk=zT3&#{Sj6cTW|YueDR#ByB9cotaUCaod_E& z!RaiOHaGU|Md&SV`};>$m*03S`ba-_b0Xw#u8M#E@s6rnc-TPjM8?Tyrdw7K5o!6^0P}bVm*O;7~_%;6cw@X6gOXL z5Pgjmc4c?te#RJIaOK!C2{8Txh;8agR99deB$eIrMrDI+_#~w6BpAc1`Od00$R7_- zAipS1a6V(zp|L|-jIYV}+IE7XIGwezrYmxd97krXSw}RR@Uv2OJY~3Ip5i;Y59Tl) zdY_o33USJggJnVy38Pzw*flQ7SbuJ!SkwJyCD^&kjVesbjm=1~!l+VXW^|6V_z{cq zA0~_2n)%7Z#=up?-*S8Z5mwsR%4Jke=|%{OnSrD-^-E}2w?<;0$k)ha=uR8A*=Fk> zCuP2&h?-!VtWZhLees^#4(s94qhOQhgR8Q5e9_@Er72%}toL{J3B>({mj{eubx<+| zIs-A<*{nAQktCQRHY{DZ0*7~m5J;MsDW2f7j z>VngRrf;aHnt+Ei0|>kDM*_ZdANNCp^0r`9oWb zmYgRT9EW;vy=UShmis+#9U-p70%D*O!omUN6SnjDXnh9prm3HKhpH@vjHgILbj{DL zB-XnjGLhON%P1WvSh^5y^2~|HWI2q88fm`43>xd8wpdRr4h6dP?feLZA%T^(i^dWE z_IWgpAkwfW0XKIg!y1g%v_HsKbK1wd-?&zPiU9eiU_5}r{fA*AUbb*eb$yFH6 zS`is{eHoo76Km*P(Cm6U_XzzFX-mrQ-FJb`!zD4x(IlhBEu9>pGic`7S)+NSNp`|) zHd6;xyY4@VPp0;+4BsV)Z%0*5vcab6WhfbyaQ{plpoA7iXSn73o2_f=(=p>lRy#3Y zu#Wqc{N1TVwR)1xkLTKV8z?R^MLWK?51@SxN#Yc*3cXAWJuJu#_BG>bchd(D1f?=|s;)s8oe}A(1`O4V%{L|+PuVptskm6C6ECWAt zMgKrlkvtiOTEaWxO(bCwmaevlhyjlZL$L*tCPD013w5vs3Z^8DGf!b0`J z0EI8wuCAVyyNye`f9DWq4&@IEHR>k+2y+Vt%5jfyWiKXF zA&RD#obD`%y+`8}Dnx4qi=PbZS<9&Y5Ho>qnFjm~6zpcewpKwAbX!m9Kd;53O zm+9_(OH*LYnTco4q&`;b+}cE;GC(i6-qZ?-W3|{as2hP_c~|b)2PxkJ(($o-vJo`3 z50<~DUAr`-e8Aktq|Z%zXug-jmn})KzuRtwmTMJKyEdd&kY}-Tcog&;zTCAZ_VqhC zH{raJhww=vfN$i_UWNCji*rNPu{$S+m1swZg`1O+&r*=nU)ZPXsy!Sxd*^K5KkeYS z7hO-Tv5<^+az;1IPh|*I2CHA`Q`c#qOHC!n&ue;Hk>uU;%)|lnpUMk>E_o3KFnKQ$ zX3$JPvROw2-w|}Yb+l7X06Xu?e`R%>Dr`K_(l3b<#g0Vj?%bvIfV&EuM-GS}C0Cxr zd#>iO;Wl&Fg}gC57sQM9+=Df1#?uM~e%JV*>rCIz3vgjY06PNc74<)g+*C)=;!;Zt z=kM_21X8l!zDq3scSwr-4Sq&+5GxLw_>RmPi!>reNN^k^gV%73=Xvc)d)=0TLiKxH z^{TYPvNF8N>FUPG*zp=}8*PktQLRS|yWAyM)JUv;)didujoNZ_6O0o43A9l(dpq5o zyGb!G?A50tbygK8`Or-{O~-d-4gFPLS^DN^4fs{;{v){R5XYZi`IfqpSmFI-a2K;R zfccgpJp6Kx?u=bBSE))iq)1%iWVqG{)nRzW`!MLa+dV$%gTE_|DB$niZk^x+KM)J~W_CRo ziNU3BUEXjh_cKC6smoa*>zIL#RwEzQcr_GHXDKt~{XSHoPKi$&wtGrF&A)c6@O-w( z5INJ?dBqgb*JILWUe(qMg=2cMzi@rHj1FS4x}{&Arijg?wl#VYRD2rxMSG=U zJvRGWR-ogJN>RRop1*2yqTj3QDbCos=Lj4e0#sf!N*=NB@Gbp8={S_w5sYIJinvHD z62jITxL@V`w2$G`A+}*!&0ko6--W`9F=6}SEN6aW>_}16Uck5wiDB(|MML91@BFeB zyiEKbRPnW}Fk#2Fk9`LP8^_$ia_gSDb1nC7oq`|p;w6bmUuNUT`#PAPDPKN*^&52d zrkxcJ{ky_SVRLHsZ*39Rndo_F7SR65c^%1ylKK`t!d{SCjd1aTN0 z6T&g zFPY3Mwg}Df>8GQ+gKA6AK>K-4X9{NdoNXGG(laQ=KDwS3{UA+lFzn;+|D{)TQIlPh zm&(U9*$`H9p(b(=PAH1o<0&NauuH^%tLwn*TL)Q`fSN$@*J3O)@6PAh<{ah$J#}|- zM01`Q7xn87)^#!n$1hcgcIAGZahSIRbVi$&ZSC*_`D^61#N0i>n5~5M*E? zy@6&gBLuy`M1Yr=Frvj`zU#=x)^Mqd>I)mXlfE-vHOI{8>bgSg!8bX>jhG0bU>k|U zZS#Ys7uBj@5b3Xz_8&c);)SveWl1}axzDW$!WYMly|s-7s#S^Y5#P*>UOmsDZR%ACX^^`-_HNFr zCa-;eV(?qKoP2`O#ovqk0ZwaJ9weyAcKEaf|AcmVadu^GUhAy&yl9{^e2rI_6kn&z z`5-E%D7VSn&HiLz3!DAjW2vf%+5Dw$CPsSIPLRzG;{C$+Wmdu>!-q4-Aybe^7Vo zX-=0;uxPJHO84yFPcB(dx?!L=^Vg7tKy?!Z>M ztXv9sUAuyjDcy>?WRFg*d;U)g07wm4($!j|J+$g>%|jiGn4)bfX!TFU{KjLi`df6J z4AHlsEu{zMXsqe`c1#yOTz2284%37EC-Z$fv@L#vpTK|Iuek;ZT+xjD>9qqA@ln;a z3nye70+v3&h^2r$@ijsLjvehsgN z!Hy)%-CqRJ%cvxYa13hB+5%dsOr%ni^UX&0l2%L2pq8ZM)G%HF-J~T&-iw?ugB+zB zkFg3lx$HM`2%7x(onYZ3$5Ezj{tveO+m?d1%SKn9srYZ{UmZU|lv}$#IE$h3#9ue< zO2J_DR%>xkF;*%>&WtO@THquZOpyvWGlk-~E?bk#D4DtT)I1MnRLa0yb0E`R7vcx9 zZn{UmH;1n94Z$AaM^EG~-7dc`;Tz5h5-UF%lEk;yZIPyZorKqN=8!tm8CK;(yFTN$ z$Y8iW)umBdMcFk(8jZJF(T0uYB;Y|h(3#^%UkOm4fN@o9J&|fHGdIz6?`C!Fw&Q-D zYxF_RuRdRaB%^r8U=AbM+e8P$5an5`FWG*wPQG!IyHGw#0eU;K9w!p1pcho_hf|=+r^J){=$W0SgtPf}X z+PTkocCvc;IomQHCqNnY#k#P=sn4GgQ`tsGAIjT@1zHuLA_mr>h6EHt5oEUBt1VD{ zNPj|$w6Eezj<*Cn5%He0b*lzkbnY{6TnQnHM6DcWNyPMbTbi~0yIdZgQz6}^mk8=3xb@?Od$F{p+u zTydayxtE|}t}tC}6b6SbNoD@`Z7y=^T%rs-_ksSbP zydFR+2EN#ly{joh8`*X#Gq-$ZVEif&bn+fzeuBBM3ju-6%b3;`}A+zbL#i3>eszOmAy zd41kK-Ni<31Yq~-!4)rsn7K?G|Yj z{sO)Ob#GN%2Iu_Do@qA=>d;LAz3FN5vYf^S*6l5dkyBz{yq(PUp30vnO^ED$rim}O z=~Zp#zv7RnZ>h};u}4(h-YcMQlS$$InB9Ulvcs760+a8hx{N%xbn~!?@jTslb@5T; z6B$~*V379J72*fraUblw&tFNN>AiS;Jl-g_)=?>ZzA~%eoWbWB%AaY{r1>K9b7)(> zz*+#8DZnB@^?Lfy;yd%`nBV*9$yeV%kj5JieZ%YLfrSKT>C9|1k&t9G*%#=5^gBEH zpR__jckP4XW=)`H-j?ZLj2s`3iNFRjDeaq){|ksK85tF5iXICUKXiFD2cSbSddPo6 zG@1OpXFl@+r)C>e<|{^?N1kx@QJW5&;x03c8rpf!94m=eA5OZ4Wf8PmQ>lf7R}brnUITf=oI zG#HXQMJDsEKiepTv%Gs<^Q)HJvIm^jFG&m2Hs~|qgvvJ!?r1axuPCwvZ_L!%)yWU@ zwo}nO9jp}%b6-ZwB$nw)!4^^3TLewpda0m$m5;vQYN1vY#~ z8sbV_LlPzUAkhp=0lRNz*Xy*v2cNox>D>WaMZy>#A#SK4uJ;OgW=Ky$(?7?fLLD9| z)V%;Qivy{aHq;3LU8r&@cnpM_YE|e*uYCpDFB2wj(=K>WF9)k{CNGDV94JTy%U&8G znpruD8{9!OQc5;kx>hiGe>Dd0-P9gW&x+~pGp!31Q{SHbTf2O2NBq zNY<8c`obT_Hj@#F-@7RhCRn<`g zB27m!PDD(b<2=mCOEO&UIWu+L`ddrY$eN8oCPG5FKtkxLDnuLKje6r8=k4X!k-nV= zscoFKb8xr^7AR>lNd0H8U+Wu!D%PkWt}i5g!*}Dln$yo~AXW=dk_0Qh_#m5Vr47xB z<25`Kl8qMVg(D=SI_Scsp{GV0xae=_?2AyA50d!de|qI<(P#ihSNQmF^|IG^gftpt znpxGu=|2%)ut@sRvjG6NNnO)9tO_O@00u#XLCTC(9gt9Dhl6cJs{2Ab2Uf7AoN^s^K-$e!qfY5|Q(xGqDrUVE@PMLk(2bCP5pe z-Mb#4=}fcNj0=zH5^zLJxF~lX>$8-2kD3)1PBjti#-&-9j@)1;UMkg&=-y;)k7zBu z|EW+Rqc!S6n_P8`Dx+l25^3x|?ptq;xllw*x{|qFe!3?**M(2p;zFyW_)$_d@$ZU0$te z->3}0w4^uzbvo&XJ%k>}EWMIoRRvW2o?Bw$a~%e)g31O{@3_9ZA^6m?;7{qtcf7q1 zyd-6#onqpy@xYt~2zK}|sUJ(p1VYz>b#I?o4Wv_eWT>Pj1|5Y~v$CW%N+NQ)PY{$1ABsd3`+1U$jGv{LC=*T*>rt4`UT zv7p8=T^>J((L~4~ZkkRQrWuKR4!jL9@AWdqwc&~Z&m&PxLFi2EpEDoO+znSRG*-$7 zh=C9&!=4D{ihY8HqWwtwcGsG-M8wWdck2}dskQM?YvVHnnSh%ACl(6Il)C%IQg!Y< zh0sq>w^wo?ZqW6O!TVdf=4kdj0`rAox{F#*$b}@)E}#yiTt>~I#HdrlDBB;7V4Fqt z*^Ru$0yZ|7jdcl`NyuuDg@I z>a0Z04I*kW?c1>wk)VRXzcT+E4bO&(Y~;urhOf z>|erq08tWdNR>g#J{o#SGu`N=NUXY!QRn_SMVbEg@ikeST;0*$K*KB5nM#*Y!@#49 z_JeQenhVQL@Y}5ETTJkc9+F%NSSor{wK+}h-+XlK%tiBOP<2l;y8mE$`JDBCkRrgq z@iru`5GWqi%S`RxYz8zi2Y6UQJ@2_VJfc&mt+(_O_XF%7{=WPMmDbirNIv+TIiJ(b ztK`vAc*}oq5GAY4fta{?=u@A9gR;t0a2ti+++9Ig1!`FdbMf2xz3fbwy#D+eWp35| zO7(sHwfN9)`q`5(Bf58Le1?lsji2TWt+-*Ai8I580ahI}YCtKJlrtTaPj_}X$jt!Z zsI$A0U&sUOBfLI7-gf;>Y}0}TI688KI_h7C(S0yNtow%0)7d-$dI~xne!ye6f{H2< z&La<{XesOK;_cHBF7=qy2+eo5*fQn)s}Z%6>P208Hh-spu1bDs#XNMmOSH__`Zi~A zK#2Wr^ddXN>F%nU)pupX#ZpAlYT7g%@s|vPppQmXS}wZ-h-;Sy#Rs5lRAv6USfD%*)+S!kA*PERh>S=#S){Q4WjymUmTfI`A(q zFK27ek}!w+b+?AEBVs^WiW9x>hO6k+=O7a|2|?VP48FSKLg54~*K~_oXT=G2fQzoe zDyWp8r$W}ET+DV)HpB1k*G3;^i(@dRVF1^|;^SZSp7(R{P)8>ekUlMUolHU~sQ+I= zOEQK+yn(S4DyO+*OpYfvvIQ*Xfk&72Dr8n&Mfc?8WFbx{*s)&B7WeMr6tmZpd|d_@ zn>p9@T_~&?8;61+wYWaG6)>T~2oC0ODFT5q5S*REvQ?f)Q9;8JcbBC^;46Pw65wp- zXBQk-J)OPyUeV*r7hb$&m(W-rJgugzr#UsIuAW=tY6N+@WuJGyU%)Kq8kl>&c0a9`gqCFqBu^KMmK}M^S*2RQL|x% z1=>RNy#&p&tS^BQ{>6{j;2p4-s3PwE{Zu`c*>I>E*UrF(p#XBg?oUjnfU!5t-7KLZ%%#_dWc zikeARA@oh67*n;Kbcg-BoyQ@KOemzEJlr8fD9BYPrNplZuZUL3438UgNB}H7sur8+ zW#WT4+u(AQ)D|_BKK0ZtUpJRsnFwE#yn2-E1YbUX8n}dP~ZS~W)M`q+f)=nV=XqhZZ4~Q&`gJy zOT3*;1-ak-4@A=JdtakD9HjWCpbb(9-`*Tk>dcL2F%e{NaaNY>2A#Ww3(W#}LT;8E zW34Tc)pq`!@Y;FrofLR^d;+$|FQrK;^qi)GdTY0f{t3x_|BylITjA>QlZ`FD-id>0 zMk)PV<)rW(M?my`x>HZ;BxdI)wxWbuCG)WHWog{35wsYb6CN44&`-3tfcoIobLP=1a!OrtmDk@5fXCl1R!TZnPk-kH{ViDZIpV8-lZX zonaV)-{i9J2K>y!3Ou|k9z+vBtQ!3Xa%Q#OsaPBx#n}+Mt;5eoYXqo|niBBfnhipA zd4Yi!GO_n8+U+8<5)6&4X&zYa|MJd+y{FT!risRrbpR^Qt7$@#G`OWh5?ivo4dO|t1q43*prH^y(IUJ~$MhqQb zlsX>T>t!j$-G)sG4K)6G=FX@jW2drIfg(M@uU4{ta`+zC&GFV0IAi)3PF=AfIKSc6SOEwN7($Zh0bN|?}oc6wBmQY9*g5@i zpLZtGlyJr05J99@?Y^=B5~H>xW{OxT*=NToyPcZ&2(gE#7@HQ2CT0pHh@GSauBM#U zLUEqFd7R`xaR9X3clJ*QKfQQ4JX?%Ion8#q+6yhD4GOyWe?oXP#uWlxiQ`MHjhF6& zXbW@@*Eu*z9a6*VRT|2FIK{rWUKxxZXxAXsz@K~99r`#7LlvumOh^(_U<9s0SiS7>&G0+`9u-v276=_Sduq0`Ahsh;VsrknlyEA!Dh`Kuom8)!wF!^KUb{bL8t07t_l~Kjp8!_IXD{~ZjNg8%67sZ!_on%>3t=`NUX9^N) z_F@esG_M#YmD*>Tak{@w z*pyXd$MdTji`LP%dYM@ zGN4fkeLhElqAqvNTSez&sQ<<19KRahGkU!Q<{Ymiv@3&t%=o1uclMfGbFyn&l^m^X zrsj!^7c3z9eOn|whynGyP|)qx487wmKG9^tk%CWn$!V7s$@F9IB@je-e$N(jmy`CH zKiH>j@lTUD`P2n=;VMA15eU*B_z^5{bD@1eh`7HUo*lpfrQC4fN-eSjS~Uo#!4Ok$ zC~SxW_V{xeQ_-CC6%SKju;1j&NZMZ8H8(*U^UW%Y?n_0Vn1nZMq^>ZfCc86;2+Nq8_I@kYqF6(3&;E z)&!aeZ15`+Kx4-Z>uV5q=p_mo2ZU^9^|ErZCE)@86B!=}(9C`;W@XbmUmW~Y=2IWl zr+MBKe)ucLI{EG#wa_qnysl`aq!c(~4IwUY#@x2>hS{?b>tD}=Bbb`iB3CTm&{v|* z=pO_Gh87hA&cqj z@zjMI@J}fwvuJ|l|GNsSl0_0uHIebxb+Zgo>FR)|W8KOOL*d)Yk2V4%r zl&%KhKq@R*1>7p{%%j}p%ZBwPY+*A^`OR_Hp>d6O6A6XjuMApb`ar7p2oNIxN~P{J z{k5?jjfDby>Z}-M$Q7b&=rK<3A5vYh#gKdV(lCq@R4tPo-}eOlxwQmSvi=8pEhXSO zbY86e=1gf|u*2T3L8uYgyvt*s3}m&g5m96v?32X2Q(^9KRdc-c2;Hr1+=>jw!`@ zPHY8p;`>o12QE7$FCVq)9e304E$iCSpnHz_lYT8ko5tR`6E7FV!&_zEXV*j_`>Xpw zV4wDjs%Sy`Q>K!}+2G5y{rZ*%_UF2E!&u3*qu*2obMq>V`oH12atzx`OTnV>Eo&FyI$L4;x zE>F1{$31#lOuKL9m$+C(Jmr(nQ}%t9_Y1?`^`1Br4~1lCHFgntxPXOVb2tM#joH%Fz?z$9~!VhKvY4(W1~Xz#tO0yuCWW=`cI-0=2pnManL?X7f&8c`L>4>S(7tm088 zPok+EyZzVdfE3WFQdefNNEAM)$OiqZ+Kyu06{g9DOv}cK4^kppQv4Q*Yv!#wBi7)p zn)>G-zty&|y?i{uy3f&|wsUvaYUOyWuTMm6C6d+dg`Xp-kg=cpJuRK-*s67M?a290 z=8DC&H^5(!F5De=JqT3?=ctck0^%0BKS%HC(8nexNhCOW`Et&Pxl(s9EjL~GQguKh z;}%2TRU&+vjn8p&9dICv4a-;oO6I$p*fk4MLtDk zhI0tviow2?@o5;44i!V-#6gl*ha^b-GFriXQo2VQC5nsroUhzhTVC2jV#_|VUli{O zIG~^}?4hvwL-&{daqjc*UvP8KClp6hlKG*O)Pp`pE;XdwXKvd2#ev9084&I}L|rg9 z7HhqYCzIe+-}#90-ZLU~yfxgwCGVcO#)fDUN)7D-!wC%PRJR2MsJbcCGlTR^G77Qp zsKGkE=Wu&~8^_5*ww{n&VVnhFrQrbiJ&VXn=OdZW`)p>8VloXC-HVjKs$gxUEvgaU z&7|>Yz7ECHRi$-AfZeN;$l=b3j9UQma2f~bU7NsVkuYAx5f`~As=u6Ze;u(`$Hu)_ zjWegM-n4%UFyFV@+Xw3vgSErJ{I$0ifilf|p{KNgMmLN_z2VzzD)8nMyS%VPea7Mn z{%ht&d)qetQEg>#NgLg?z7qG^J%qXu*Ki*}7kP*+*&01}epk;^H6v1VXAV80HA%}F zN^X41ye0$ERqSEqfwIeqiwOIhqQ}rE1&H~47C9V zOsagcKH6V=wg=xo5w9ScHS+n2}Zehj5js535~x{5+aBNI8z-C!5ACNcfd|ZBKRr zsuw_gZm5H3=~DmbX6L?P?K}Ma@ScpTd5V4c!19|Cni;)h4*1pZR(|gou=|_rOSlML zJ+KmxkTB_aO>13Ez&^!?2nOuTE&W>6pFHDU316XD`ChhX505&erUg>`h8p(^9lp@T zHAt>H(G5RF5okLlF&GJEzaoePJ8hSa%yYu0%Bzsw#uO5QQgPEAGL0 z8z0;M#Ed`y*adfcPsw>Ujb9bwFB9(96VTdsAIY9hmt^EEr-n5}gaPGSoBo0bM7Ver z$xt!~hl1Ujk4n%q@A7=dcJgENBJy@jn5_4fOHH`N|6V_TpBG+RP5yavAG zk8}lrl{^WP>)K6vpPMO6H`Dvg#aX72BN+smk%T7+N{kFJH!aU1`}n@G1_yg{aSBDdtrqf4H3(8jN~sM zwPXTsZ0k*j8P4dnv%Tu=#Mli9A5SiaPTH%uWb)OqfZk_UqIUOtJpO9Lj72HxofGjR zUv02!ne+5JayMxUWf(-q&4-x>(G)b*@gNn(iq3LZN`94fFA-Emubdp7O|g-DbpAsL zhT3Hvz?6ue5tE}Kh4ce>qniN)s@S6B1$vDuh+lg#w62!l)_L1$nD{h*V=m#^9o;eT z(KgBuJ>`xKWjLiKbJllmKc=U{IzrdK5P&w-x3j1rjAFGF$|D3bn@l;Jg^YaTIF#oX zFVx4gt>#7v!4<0gA1y>-xGk87S72;Ze~_iUCFm@v-23x4*_60%L!w{j+G&=8E8Fwg z1ZwpbA!#ljds7y~t<&-SW+mNjOdngKAe|{>JQUVCAK^nF{0Q>2HiX$sZShmk)D*lKq*gx2o6E)M3;$Qmv{*V)g9^ zE&$=N8w(Id(3`w3Dz>Xl z5h{CTL-R>Z@lrl91gHf}EC0JtUxn%82lSQ+Dku{w%lN)1B;uZLlzyvm>=~ok%^Abd zY-3g`8Mk)PRSHvp=!GDbvd_(`cFfk28MQB7_uLeVC%W1ZwqdT6n3_O5l{|t1)8meZ zaJ+%Ar#tX~<%;D-()Zv&8xpeF{Tpx@)3t4((Bf?_OUr63KhV&aUPa47=$b*-tI3t` zuz9UQtJp)9S6e9bIZBF*r=4@#s=~0S(5W6!0sIoy5{B?#u;#ITzm37`%wD)PbM01E zzmQ*@?|2rPFbR2Sy1{iP^VxXvD#Any$s0fSL@m7yZbU z0q!!iJD#zq_Co7>XRXOb&^qN`a^ts)7VBc}V6iV}qfZ>(e4Nh>U1ksJV}0abQ)Zwx zi47{8TVdhzKOW^J6E=-JAsH%kf2@^4w*`NU-jR2oeIB|yqVQZi4_E|milSJvDWH82 ze{n?X(?8sscqefwOlSP%%_#uX0|xNG(u;RHN37$~O*F^u^m2NH?em z2nYyB4h@n6C|yH$49x&Tyw7ky-`{%Y&+GCo=b01x?6c1~!;kBI{eUTk9u0ndEMzV} zY|w>b_smiQV1cQ2*ikYlFCctaqReT<-g$7=FG0#fc~B{H(;s7tyj75!q56nAHi%^( zL#WHdlKp*crLR)Tt4ueBB|cWt!)YHXy?!&;;lo+$B-OZax(IbA278xal{o`RH zes zl2+K&HbUnE;Sk;tmMWI2JLJU8xU6JsF!^$#%bzYl8y)v%t!KW-j_Wv6T?@8LJrY&Q zu?B7x@{GA{|GT#(-RcAUHF+_oQ_9nH1zs0rs=CEYm) z%;j9f&1B?>;kkGl<(jCI{h`XR((5ry^GtbO#!&crQKJUEzJ3zDh(5kipIywLxxW2( zPhoRLC|^n5#N98J`Nwm<%I!0Y<6h6KdbLZDZy#2E>u3B#D(532o9)0A!8mPRUglQw z!g_z6obJQq>lwnxsP^D?9_;xs2f1zCwrE_|E>U~?YA|0Yu5~_AI?+Qo@O5jdo+aIa zmG#viB9=N)?Kr*$LTq-$nX-Z%w@}eEOd{!=+moD z$lZNOuc4f1{Jc(_p?RO>e=^@b;XOFY4H;y*QuH}EY^W{?Asa``N44Xfpxo)i0(pkl z96Lf#i^%=zn|9Fh-&4f{x7odzKQY4tBAH(=Jr$o5!m_jsM5&r=dQ#N%{eA!`Mz%hb+t)4d9ogCTMcpb3FtlhX5X@zX56uKCia^ndC@0<#i>6>?00Zggwz86 z?B9BzVUsq=`28NY<=g>m+Zl01Z>kNna`APb!Qq4P<9`c*{K?ZfW|TRgSjytme+rJ;09EgE{H%FZQO9|*L@HLx@a zkfz_@Uf>Nh*ESwYlGka7{rU`In*sH-rFY{3lzQ4~^%zmzx91B{GT$L}>brA~hRXc( zK`%R)&5$ChQto#a8aCRkbG-Zw<}UQm*zM$B6amqO1W>9LzU&ygRS=&Bkn&n-R_gLW zcntKJd%n6rX34SV@n!laRvc2n5q_byhRMW)lrZsustKHQA(AfIXaVx22_A-sel|gi zFBb0!=vGxR<-RV7tQJa@46Y^HIq(WO^JQvhC8c&EewNsqJE;4X&e8DIf-Pmr9rx(k z!%y`8uaFdqh^98P*W3A2LlxwyBvBAU{osk#AbzhJ4-faZ()|{t6fd73N{O5ub)x8O z`O49RFE(J#Ut?R+X~A8%Gix*hs=4Sf^2PsED6+t9iHr6~(f^Z4oRWJiu!ykjK8GNx zpzy&h9%J4HsT@gLgs)qW!Y@M;)bsj|dwdiwdV7ZDu0$`l??~ePVbvd-T3zmIb z?gM%-gJGyod z6T4D3Vr*Yq8}%^BhLT~*S5Z0g?f;t&8qO~DJ?Y3@iXby8SP5M*Eo%C`oIPalG^f+Y zW}V~K^P;Hmnpp$5K~w(JiKvxtkflQ=-}Hc)1|nh84B zSh|P>Hq);b_O0U!KU?*7ehqx>j5c!Gv9X!X=2Tr(otDC}SZzL_+_(X|x8 zROM7oPBA`Ryan$~tyIoq2x4hw6vKyU^Cr;W%56Q%=}`ES}4Hw1^$_L?m5%Ib@w;MNJyH6t)mia_0Z9jGyH58A2ROdiyHLI-Hb)KtB zNOXR!i7MLMM+8`WO!ZhVAFMXFDffZPvSHM|2*esz;Lq?Lp7Qv+P*bk2ayb@a^fZ8(UfGTyEHQAp7!3LMz}Ac!|c-U z=y2BUiIEbfJw@y5x!$*YL;H3OjOpz^mgUCyGJ`naoht?SbGw818!7E#o%mn{dll@U za#Aq}`xiR_kon3?`HjsDz)l%yI@M^O1*>JgrhXiEM2h5^>?~b9iL?AT3y6?S;N(J<1QZtq8V?qnCCuG{Od94yjNRGEs%(CxVso@PLP}Z;Clu$h9*c z%JgRI83TB5L+z78Fo9CuG7eiaC)wbn|>aJ zgN^xMs0qDEj{1%DtXW2=hJZ+wioHfY=oYnumoF`SY8zN`T2wa%uWja0)5Gp4+xs;V69 zFGCO&@5^P+mYJuJGMtCGL;p$RMR?jB`MM^G2$!@nh9Df?KL}va{Zij-zi{nuQ>S-dwkw zuIA%x&sClTjOG*zLCE=#)1=1U2Qe6~;Kw-U=R*AjJ(5iKvKSkRkqXYqr@Mda6=A4|5PRRS8P>7$jJW&rn!pm`G#R>u@=mG{o$9%`5 zrVk7Ui6|h78h#!X+~iBN0!G0;ZWyQry>6wB@pd^<4iJ#?S-py<((g?vVnL<$+s8jr zA7AgMO3z&#hqn2qU=D&%~pAQX6k;E;zKjy&b=LGG^%EK4-ML?0B^; znv0~BtIYw{!>pu8Z#vH- z8_xDgIr{Q~viil`6YZ~80e#h;8#XM_oD61#_-Y+C1)M-6s!&YFQf7(OE*3S%Z;Su_ zkQlVrocCdy`r3}p+dQ;>1J1WPK;GKeqK#QPHLsuZS-jp;ch({r!8oYrO`b50NYZcn`SN(op$sQ7IKF=L}o#Gy@Q#>{*m zt%w`)`Bf2=3F$QH8aE1Ey}G(0pDY(=G_d-aOJTIzu$r_VU_D^?>LOjFer;x)f@@~G zFDX)~i4;OeQTLQA<$~hWX+{LJj0$nHyrvfdF^S?{pouEnzqt(?e{=4Wh|%;X6t~cE zH?yc${rYavw|M86ij-UUmltxok+Z}Csqvii%x4+`JBb!a9M+#&E2qsR|LL~6YMS6rg5$*JE=*E|Jh48*ofv!G-XwQ5^TE5O9ey_% z;BKeV=v-5y>dUZEjiUeeSGTb&$tb#TMLJ#&sZFCy!+(7_g*2&kT^|Pa9`%;H9wmlB zgRxTTi3ntVO#uPgP_WTF!3EiT=ZYHgnZqqVO#*v;`8kou)NZhrEBasrQC!^GWUSHT z)M#7~-@tioWf(q|)X#>10oEl#`ZqHpr|OSApG7Ko6)AboP8Uh)4m|?@*B$~uAA>2! zujU0F{b?)TV^WrEMz&x59*N%hmX!jXy2t*rFS{G!2V=}`Bj&zsA>zc&Ge))T-Y`u6 zEH$;Uglf)kTrw4Y;)3}8F0s5{lF{P7fq;-Yn6|k*4NoU+(;V`F}}Y7n$16*ipn7D1&(c_jjTLdRk3@e^WA-z9`o93CzhU=q;6By693&l6#=G=jVBeH5!`gf$Z9og;a2zIV?&B zl=|4FK{GEbyGQC#TOlT00FMD8ql>>5MHNEeiqpfemN7{O27G^qMOmtWVTow559nAO zrA$(jj8K;G85gbQ1W);Lz}wAVqa8|X;^wFWwo4G|xlhk<(oUil2k3cmF}=q(We>9> zz|XOazLOPZ8Ih(Dqv50E<~f|1*Ey7@ZlG@FZf`J5D;=nnn1eKz@t%6=9Uk^7lamly z{=!gV{M&C%)eQS-oQA&KEV1&(p4X+|R{_qjFz;p%$9wX3^_)E3a)7f^^%cd|AoELV zMl&CN^^QKXdj!VH-G3i->usBhj$(*6L zLithL!g*mC0~rrqe*3!LoqV3Sl(|-v;k|W9RTpIBs9fl#=GW$98n78Oh>Os#cQIoK zRRCtk_|F>BOgTXdc|hS-g1GYHjJVlXT^j&rE*wQap*w6_-So}O3mMB(T>1;V*TfLC zOdf5Gvw;gI>frWU)f=9Zap&xaS?m{J&9G`{SJ@^ z-OvkcWOz4T#}PkY;S5w+c{HtkWWH8tUN4hcBrEV`ISA z4f%^|1oW3DR|&L~K++BLPF$gN0haTn3L1+?-N~VWNwfq)Wg_l|awo$u-IFOz z%3Rmm+ox*lSnYL!zNEp9$2$&XyX(V;rNWK4!772II05Ez73lZVRC|`AX-j+ma3hZ8A3Z4i+2wf{?4xuWZBQ*sc0#zTo1IgvV)}T0| zw=W?f396EGb@kG?ku#Tk;Om1=&$DUxrQGxAy#NSUl0ZvN2SOQzpxw-6&fdyWs4Qsf+EKlU$4ugx~7|MZO1u46UkX&5pilVkT5lXLr+ zSVz}H(9$_o9mP2_7#X#(B#$JY?xkSr?)%G*c8KohM$}rL+jM;z(oW_V z+zLgdDwi_FuCt;oqebC~?TFvAR$iS&4@J!v&w8$#)DYVS;lp_aB(6_d4+GUZZw&IF zz=foFe>^YB#7m#ZS=dGzKRmRvK3y=mwD?YZ7uF}I4Sp}C)8!@T=_e_N0+#pg7L0#- z8##ZL*eX##?$5>0gS!ab3Wu3c6M}7!MDW?~*P}>U@@sE!>hkBcnaiDEmIo-rsP&1x zsWRHLbzLCW7`}99&yOS5;^2Zi_+06mx&y1^9q_K;a~|39_$SdrTD+TDGgxl$*DwS> zs5!l1tA!@@7~&HT6pZLjiX|Xw1VVZZ;&HrIylwh^OvR{&b0V6C;Nwfx@GqEadr#R8 z83}JUA_cY@9VaxWv%emJlsqDPlyUL%|DF#K@m98X;yu-txk9{14NR2H>Swcna2i~6Z zzdRy;NDy5*->_R_;2T9#)_r$=aGI*_tSMUjciojtXP*dCbL>0SRo~Q~!{qy(VEQtS zXcZIEOgjO5l_L2m{E~P6{{Nhrz8Qi<{=44ljV*40uX!c^F47$IF9YNHsnuSnRArNlL-;wD;Au`RUwO5gm#q#C_tOtXS?kZ7T}~ zH|d*~dy*=m#PNFI>qAztTx*>^is>zTvGs|NPjH!Sw;x#gJP-__nUed>zmE>W`l}o_ zgz#HniiK;&Ki6jd-@~~nT_W}iX`+w58oZtG9o>*+v$(u;O@Cs3fA|M-F)A6j?!ywy zFD2~1tb^=xvfU>Odva_e)e1rtDf$89RJV&49;ARe+s_$SEAaL(Mw=WfZCQRH<9GOK z%d~Y?$8!0ZZOwQU^XaLD*x5QAj7BNYuUk5>0FEA ze6BR3XQ^B4iOl%@9Gtw8Vai2#B7?%jWRiW}r(IH$lYn>_Zh~uecMqK-(0*)LtgBgH zQ&O_0FND%S+unRgb#+@t3a9ax-56%}61EJK282CQ?2`heREA-|ymJw0vPTEV`EEIA zXAX@|qkHP+3t-ODbzm0#|7igXiL1ApzCU{xK-w@hkMTEb7}GR{`P#9|xXfvz#<|FG zQ1?iWl`KE11P=`}L1H6Zoz%a#E|NkXETJ+f*6eM)zy4x3H(Hu7G@Spnat7I7#q)Qc zLUc`w0h)1Da|ds^QCRj!qXgHTHyPw4w>i2t`?D;Q6yCsC7PRoGmVTt;a_saQyohCzW?jF}10$q=8HDB(HkJ3IRExAihT~ zR9?C7uJHEEBie!4N>h6N){F&pz$yxCL6j17B}Ito!zrz}#H?=3D3-G|93vys3*x6JN)_1YlsGk}k zu$~)E`UI>@HS7{`y>BFA zS^%AUcdrPIHJGf?Pid@fbig&A0T9myw-CyIrBq~Bt2JynBBEl*xqiC2BkSeu)81MZ2~f;hAB_(}W*)&+@AJ5#DrQqMpm}q@%d4aDOVqo9^;k(x z$fx@r*``0)=WT{vfMPtDhX@lKjbzEf~Q4MB|$YV=q{HC zr|R3D(YfpIzm1Szqq#cV8l)X8a~6pm&jCH_B^vLy?h+tb$FX|xTk|@N`FYr5q0+_t zqC}Vup!H2u8cZNk8lJp^n%oj6g*@o712`Ov6Um+nS)q?rGZcz{a4AT;6h}Os?10Zv z$^L^VWa~d+V4NmKT&ew62NIcue~gf2rt3Vz_`*xwgY6j8SnQ_WW20x@C8?*%)Q2`o zNG7xE+Girzsj94}V%oh-8Ot9lhWS}$f>(@qCo#OF<- z!-mRJ*jlr(P*#~SWOd=7BX`GnWASCI!)a94Ftd^CgP zmYfE9!X|jzQDTwH;0jP1xDoBylKmgtv8;O_?M=7$srJ1kF@!tv+xpgzZbg~-=Kk&M z{}N4@=_*O2QnSVBRxp-p7m-M)1bsy@d*7=-@KeMQ=Y9KWd3Qi(X@H*bv%0aXUk{Gf zXI2JRG#k;)^OwX^pw5TbDO#9Dx1wX?K@B3EW%pW3bA0r-)f21pkFNFs)}8+e?XZNp z1YgT2^`4e?#_-1u5EjHxWqB=rYsUE#9c5LcmNy%L3VI%ZPd3%rjvOEReX=3qUaypC zm)6oJ$^0I0mQ3R#JmEnEbgC3|B(nZHo;IDI-@f%=%ABT~8Y}3^&nSMkZ1+_tTPy@; z?pcC;egQYsZN8PjbH;y;C3|v||JN@Oa|=hRz7zg}DEYk-av;bLTQ1Uvn2<|*OZ}~3 zc^lW{2ZOI+HlvGV5Gj(kT#z-GgP?fMs6}O|KMJ5A5ebj-pq19W{S#fdfe&s=dPSWY z)3=5@ExjNRelO?Xj;sB>c@j^yNC@(9nvbkrPqT2oJCW575Av(*2-!R(p1$P+dZIYV z8zPQ6M)WkJ30h8(%jR=a<=mXS);9#L2Q7ISI!b@Y|Nk{C`n!C2szW zK^E^-3I`W+7{0L>W5B+25UA{Er9O(%sddk5b~{XaoL)^yCKn_t`>dqa4-F3LqL=w{ z#_X3F(d7?x6*!4KAcSa8= zp;8&*E1W|CNW|W%fh)VwMNbP4hXfS7*J*!AWDw_m%{*n zPc_~l3-T}iZ>rx55@l`nfemAWPj*h4xWE%0vXJmR-K?{8;{0DS<|+*XLGaQ%z!6`O zcWpH-;Z5hc-`#N*ZgU}xLZrDgHaCXYBq8r4;jP!BT?Zq0tYoxJ&Bo*b+Z1OEJ1Gh63!s+U_;V#F_Eyt~efDV9Tt_LEeBsD| zEjypB%M5Qb$ocr7mI}??KxiSC=gSVG#1t_^nwU3uMhc%P_+>ld3i~3*t&-{u<;28S|P^uutOzjaIJ9ZnJ}=3TeS*jqHN zYjH#+#7j#gk*}_-(SPFliHPEjwQ=2#Wn!lj`k@@cr@X=gzPD0zIJta8!Nq9?^y^Tt zi0ktGy*;)nCnksEbg9dfbOlLKP8N=ih;nBkIU=84wfQUef_v~LnSAxAD^Z}BemyKF z&)~OK2Uxxg-m$ry{BVYoQ_W3B@$7}d&y&@6*HNxWia)t*Fg5FmMH_2=P}nVpVg)|T zQS%qJ^7jbr2r|xr=Fm7Ah1?gfkl3jPrkHT=FjbqsXb_bH?lwZ_yh)Xe-9=?{hv@EW z3VFGpUK*UW@j-33?gh+31pQ23(r{+6N!h=gPHoG1W1M}Vmc!xpCJnTL6t2Y=5M-Eh z7;>EdJ**B{emXSPCZTfXBnN_-rjP?w8xp!ptYaf;@$w&3Vh5jl}P0ne@W4 z+4T3;bOQ3DWY`S>V@$N(UCLZiB~NxN-nBPs;rL8nX6RnIAx!qRZL|c~ESxTyRdRLs z`D|j;jk6n=ujkOILuUEZ4t^;JX(r9W;MzxJK?k2zLP5ym#buwlJb%#Uw}d9%8Hao! zL5zPNK|VW}Tbp2!cHP))E4)l@r49Ts18#UjJH>Fc12FJ+kem<}GQ%tt>b_NIb6 zRye_#)yC}AhRqr!OpR1lBW!JQ3{&faq`3c&o{hvcBrQ><2y(Nrz_;aX52hNV$l;yM zQTqIwFCCjo9&71s1;Z4Z@JOEM+qKEDc4W&gYU* z{T}+D4F`VtWu`;pY57lHu?0UBZI0ocRJE={dZ(Y!O{SO zo;}?_bdnYGwGD_I0Z>Y4Soec}uU-(vnVC!5yv26DWzYYUWOt?GzSNlF+=fs|iEY1( z%;U>jbybpl|9k4zx;E|aAjmp;rS#5uAU*)z2aPfImuyQP&xlMK5ioSoojlO;iB3fVa_yZnQmIDE z_hUR7|7x`@!FzsZ&9#S3)|QTV<}61*pkXN`L${IJ{m{Psk3fa1+L5Z7m0|7w3b?7t&bTNWac zPFQlpcGRut?h&tZ>Ys@T7&TUE4%i8Sl?0@E9*}>_O}5oK^ZCDsp<4g2V6&4@A6kLT zT&&aRvIc-uG8rCt&ohcu*E(M&-{5!wHN7g<=oxrt>Zn$joU;x066JO9k3dpd@ki-Cw7*pt^K;xNNm8fX=p-^Z5n>!xK5YO@-AapOV{qzqg5Z=ChTp zJAagBl-;&|>iGzXs%uPp&Pe?Bwy8nD+!$q!r%d>;AbNWQ!=7nvNY`aV;yx89l8RO7 z$j~)F(wZ^t>$B6;iyvKU8>WuJ-WT}c^B1NofuvV%d|EjAd?!<`XL_SA-W5<2Ioh<< zfAR|$8p`X;%=4=4-f#i=PC?EAkA7wWVUsZ#gvkl4RwUG#&tMVABc!k^YD5g=inQnP z5P?l;mr_R%-9<j$aQ`n6 zN~E3vBTvy!8SFfLG2_YoN(4pz;?DIla2)q*~j zIy#9-fr`83;HA;L6cd*_i{%s_ZOtQL!K9oQ05P&O<&hu$-bc_fZ@bp+Rdp<4WVVct@ zhJ*9Ef%;h5k_nqEy#II93Rn!k{d=7CFs}ngqZ99tvB{&K;u#Yq<(A#l>3;f=y?ZQ@ zBB-_Jpre3rNZRL>Bp+K5)QIj;%r}B^-Giwu!3RH?-j{v9ji55gk?89q9{w^8?%g5K)FGy44{1nbjJ_m*%G4nO<5 zOEFUUe3W}9HhIMUUm%~+&X3|WN~wP>l`-Qz@Bt84J&_abY$F;`2CMlp))P(`MO0!N zN-6EY2jte6{RmIam;ZbDCGs4CYXwGGw&^DKCH#Ab?(X-R*@bH)e{=3sH}PW{d26k}UTS+hruSKIxsJ84 z)-n?Vh3DfBd$y1q1S02_-6A0qqkcx(WePNhSo&1>3$Ziv_)k3ghAucaf2H5+w1iAu zM{BPdsL4FS8OJzG46FfCkn+J@pzGge1(2Hej`a!Eya0&gab^oaAkmsS0tOrXiKzy~ z2Jw#L9e09j`7c}`j{FqBx;A`{`lA)8lpO$tU*? z*-4WE&(VEpN(ePj)b;k006?f$)RIdY#qy=6f4XG~VK){79-yJ^iH%^bES()1GKW#)>CLja4GS`$(#ciJ;9@;fTFPV3=K!_&m11Y z6H_&jlFAff_ioB^1>|#YEVYk)Z#w6^2X1poaaK*R>G*FPi2``iqIt^sOp|GuujxJXq&4?Q74J?+anwGC6a7 zFD}debbNhfhMuDQ)*kUenvuHpIUN8rf9LMU;iJjj`fv5iPu+v0dfY!*TH3apJ^xc0 zuT$0(U@SXh29z~mg(m|;`wNj!Xq8vA?f&!1Pk(QX-J1xQ41S1y zUQKm*1R=2NTA5hjWqmHI%HVd3AIroY={|F*((?ZKCRMG1^|lK#whom>{~;F34>$#H z$Cy`&h|yc?xjPk*H|nA9K{xh3d_^S(>*OZ*FufFo2hi_%%)F?83G?~gt`!E6o|l9F z)=@hHIC)|MFbS4m7A&vXiC+eqv5;)F~&6Bf2S0+Pm|`Y^rg7-pRe&aOW+i zJr++_{I#mdkcTdJmRg$OFDWn0j=F?uzG-b8S1Sg_+WllyF7 z>-WU+EBXh&?2$#uHp_PiNkl|| z+Cp;`gs2n@S}$ZxE!t5yela3m$MYGxAR%?Ax|Z?a?Q8`iiUv>m4@f8q+zaFmR+UM) z^6r7pZGe0|$Dr2@D)M-H{h7@Q}ld;QlfI`ECAu8>#}79);o!`~<(4pR{F4?wd=)lefzY z@!%(&YrLw~gQ5`;FZm4xV14k5R4AQ5KUnY!NsT18*QR6K1n3mjORpw>?#~j<2kU`} zNER;spPSr6>kZ~Rr_g$XW4;KSWB)E(8U9mUFO=pcg?x9*NcS=$7j9j1EVJrxgdFDI z)+d|fQtM!BKHdm{=e(Hq>#@~E*+543I}MG}Ter22anZu{b9iYv z{Gldd>6J$l?n(bYkI}-)f&9;7-o~XInY>SfuOJTLmu~Ym&*uW`$S6bzZ(Yw`27(z_ z7Q}O2haI8HGO&{t5lGQf9KR)@1(;T&EHF;#5G|&VEHVkUkQqMsuL=JW7gCS%pC1I( z6#AoBV7H&SHqMcblH)VM5PtxnrFa;N9SfKP!}Hamiue(Bj>zuMh4qcgXwtgu&MdY= zgjlR4%q@xvH1?cz8@-LKx#XTQJOoGM!oxaxIIK9+*W~--hPQRu|C;wO6y#>FT{>BS zZUhx!KEm$HDhNKj)l)V$|C4VCr^psEtE$k1)CWFd#TQxwg|JscGfr23lm+PC6{NPz zi`~h0o8P+(;LHL-8qg*T4hTH>!UrmH*6o*kJR?P0LdNWuRA&Fr9L| zJmXgtVih1)+i_49%eLWA5(*qbyzgt^tA}Nk-J*wEXd@gKcA958uC+(LPg7^)ZP{%o z9ErQRO_;11A#*;HiEAz|gK8Je&}8!5oi~F7fl9#zIr!~QrJMgC6y=Br)W=d}Z>YX#u?!YlxJ@hdp)EJC`}tpuyZ@?T1$FIHSXuCZ8X^ zFq&KB{rS@sxdp!@HI<(+yB(}QU;ZE0jn8UN+Ha%{(n$4_dwHRAO5AG?6vti(a~yXY zgXH4-a&%DykiH5s^=SHb0}HSYU(@CydNV6hD*u#2v@}x;^~--8{i|I9BEoodlW}A636)<2fkxdjGkC z(GU|>m7fs-Dm;mIc1|#ug{qG>=at0LK7OLGO1aKGwv(EpowfsFyaU3yvI6hF6o3X7 z6YeY^zE;L7JbVfP+}ItTx=GjJG#96!CjlvPK|zZ?8oUOJnx2mXHhdIh*jVTIv$Y*C zG>|*a8TF_Z)TNXP6Z%|W&j%tk4(ArELM!?YVl$VT z5EEQ+nF>ucxtKNhB^EpgXBP7Ud)=VD5!jZ~)H1>ugA%Z9R;qlJDlJDqLD2ncu_XW@ zISot3ICjOM{0gY0VMHM5FLA_;%DDaC=JcA@Ptd6a;Ht9!a{BpsagU-Gk6kc#+~8!A zRURI@Gq(>5LV+QEw&D+-DB9<8fr`xr-ducT)MYx*1qrgJPiQZ4kD!vbw>Ze&GhBWR~a`2 zs$U~;XE!7t5E2CoxPFQ!wk}8u_y@P~Oj!uP2}7pPfckL3Di3-s04H-yY~ri*TN-%G z(EdgV_iF%6Iq&^ho#= zn}1?Eu{$}4R|0k~0WFz}0%-iGe@Ury2N0=lyXl)lb=Fs;=x%-5cb)rpDz^ZVhJjRe zsW{pee_9svl)>#Cx{9$BcC8uxxHWwcrtjk1@s zGH&M|(A`q8D=gI8{;RQB!4mR>xv|-lfT%)S$FzFXywqa&7CgYdzU#LKw&XR;i4?H1 z5-M36v~F#11HA-Z>(2LE5rTwpMfsbwHG@VHFS|ot&6~gjvY|PN?|)C14#K0j z?>~d7`iRdDoQ{h(=LWds0c!+wYc~^rECF$)`bF(9(oMHIXRt{efx-Ivd1Neeph`;( z6wm6K^6xtsu|*a%2nHX-ZVKzo0N6ucoRt_Kv&-$ohFC;@6h|q240vB7$Y^9Zv86nR zbh#C43q_Oth-Vk+=>Mpdm~^#=u8OvLEbExY=TaD@Rj>VrBAH3if6sK~Tm1M(Q+J*w zEKoZuy%oAZ(NAQ8E_g-}X_9!T9#A8kN3%+XZ-~xzXJ`EC+ zBQUE1dQw;)PRxyX647KSQ|M^aQaP&9aC{Z*e-WYKGkquF9Q6>Zy0AJc<>Y&I?0mS( z_~(zpZz{Uk&SV?vUZ@7JaWhPsG45GGu6cQ|YUv}s*~2eIP4yf8Sq4uja3x+z?Ae=E zBHdbvi6|ZtYGROp<;5)?^ZnK*+Vggg-TQyKF^r^w+ugYLF=H_bqkr*kgRPw{vX()Z zo&Tn00z7BvnPdx~Cb)^6G|RW&-Yx>NLXm}WM4%`SAL6kd1KF#0*&fJ^=Um3+W>h_G z+HnhyF`7NWic@!>ZTJPf=HxjgR+KCC;xnL>KBe8`fBJ%qC_GyJM9ck}pGOqIL7`10 zee@pWVSEw3vRBzbM;EeE`ulM*I$&08$2oZGACk#+lmEg@2+H~tx7i4g(>=sRz2>R{ z9rwUaYC(4Yi9Niffq2(Wr~6(eGtZcewRX7+Z%MlJKupeMpd<}=-rO!$uyVQdm6q$M z-rWY9KP6BOq%T8k&FOPD z{nuYhQUS5qchlbI`zxtpy6MM3_9r!a>3n?d%BfBZGj=)sRNmr@|H-{u+49pK8VuUy zz%Be&W=V}9W2dQa2vlEWyCS~d5*#PISggv5%Afoa=Q@HCW>9*)zGEAkgR(P3exytn zeHh?(v92>V#DB97!VNyymf<65n8DLGQD4updHM-UgH>Wa_n=A5t=#uM0DOazdi}WuRJsRK!)c-?9YHGY zYE~-sihqiV@CYSapEuLyRF3>;u$pH*NCDKZjz}8h+7nPDVIAgTdzTN|kcxrg{kKX5 zDz}2|gA*)ZERF7_b!igd2W8yDuYXt4?NW_(nhf?-3E!<{i}*%W-*>1QDU|ME;ADN@ zlT4^_9ubhSoVf5E%bOnYfcG0e*5Dr+Y0K2&7gk}6{R4=MF#PCI`VZ5=*H{h1u-T8K zmM2;m* zs2#uKI-S_G?^dZ^ul&~(dOefOY5eyVU@oZ{Sf>CI*bIS&K)Eko{P9t5o5N}#Tgpt$ zT`96_53-B9wmjZBe7Z=;_Xr(X)05l+N`MVxI3S$$GX2_ zgFGU4KR3qyr$}pLtbk=vXmRV~tZIm->HFF*raH)vuDS47dsfUFrTTf|e_Unq>AdTSmrh&JMg;DS6V^QKXF)P^PY(SqM=rsb=Lrsa~-gdsuq1nGhSHm!+B9YC}}Na>H~|WxSyontuSL;dU3T1 zo-dt7bW{v_>yP&m9<-?`T=}|4x&%OcW~-Q#vX?vTSoNlk3~?7k=50-DW;M{STY*3ey^rsgTBd1Riwff)d+5?LPYTC_aorVAD7=cV@H>+68QedJH%Z zgzBdSo|`pnxV^&KW1YW$chJwpaq^>I4DvQnkE2#iio>m5R})^OU-24r5(E!Ua%NYX zKDXnRGJ@+IJ>v08pN+PU^}3(KaBphbWYLFrIL~sY^m&|lk#b+|ws6*c?%6rDAA!UA zC03plpX8mqAQ(}!I1p*{=7&;>5yonrDU0^lJTjv21Ku}nir8`plrS|ITQ>YRDdpt!scIJb@4>>96u$>`dRt?%$!oH{nv{wUj z*s09o(r@+86+1YfF{tjNcW&lSy>r)QzkQmwez5H#Y?9RmVVFmAOyj}yhKx2Oow1gxFVUiocC0J)YuAlzO5Q9H1!#;<89#Ktyfo=XBQ`;R|83&j%-CHwY0bHd@Y0`rI4rb_E3zm4(e9`9p2!!y%AdG zhb65)!@DU7(ucF9(;8j{oR0=JW_r}QIkUIY_x`191-~NI_hO`e$$T^ZYzE?4tfRB(pIK{3&r#ckh0MEYzP1>M646xa zk-&zMw@Z7X>dn--4ba{mLXzQP&~gjSy8Vk(mrA;}zz-J82k41@p~{zkREL+sQ$6*A z39Pj#q`k8(&4!5ME2V^8Ui89f5J(;j&uhY}ko4YSHKO`wIQN z;bYqTKR33#U4$7(D}5@`Bhug=tOG7M+ChQ{TweU#;4PyC@+rPW-(PZ7E z8Cr4D(he80o--2Qi)a&QNoq+7&7resSa^b|NqHqckQ?5xt_J)#Urh0p%yQ~j({)>P zi%v*c_Hk~>M_HQwvIq{twF%D_s~?}R;@t^@I=qF;*!NLrT~NT=ms(L@0r=z#@CI55 z9P1d=g4SEKkTS5cDL=$|(_h=@)X} z1~WvH!Q>i}++g9R76cBe{Ag&vzj*mh&bY-zo<#q}F)aL99%bf5cdD2belfC!&`wIl zSjIE8H%(o@={@^L;n9=wu7ZcsbE?67@qdb1|CLt)_3Tu)-UVcFEO8%@iT($@Ge+(g zK-`B``z)cd!~`nybkw%jOWMJem~}kNKTxWz2-}9^7PbVsJ@F*Zj+ydbd})T^SYuq988l!nzK{VM^*^El%{w5bIYHO2Sj32RVG)BKL*j`ED`#H)UXu5dK9mK`njiik$E^ z<>KLv_~wHMByy4bJX!2$X-xHN5~ls>fi{+X@Fjm@Pu#2Y>nT}vt7-%+m{ku8qE#piR`VKCOsxNh`@K`94-y|1poB)O9RZ#avsR(> z4=1)Lw5B|ML@X|Rb zHX1)DZglw^yPIC@Uw#!*3Lh}wZYRt9m%A^c+`UQ__vqe{^nK>d=R|ibqu5-88AG$H zn{m=d==5%QlYD%s2}^`cIKSb~+FBr_?x%R<6o~GM(e+&qwuW7Ik8uzERHN?QPxE`!J%KI;8$Oi=MxI zehR<@E>q>IM-9C!jA_*0Y|tO*1gJ|L(>zJi;}Sf*+i$w0(jhCbAaA1m%x(}E(|<+n z%ny9{&$EafJTL)}Vq3d~e(Zhv&TOHG;pZohP4K$g35C-voR@#jVwk7|@6AO_2PB{x zNY4&TfKa6R$27v5UvGG%TkUq~`*|K=I44u4+-*4?PHFr|xhk!xw6Id+>5C(fk&%Rs zsAeS5;AP2fJc%PPgJ7Q=iFS`Ad_tLjcjx-JVw=ZQ@_2%qK+HSFL*1a5?%DN%JYz`) z??CIr`OLG9s!DUc=*rDp8GvWf%uE&bM96a!3H=Y0_gX~rP@P#EWaTl@O} zOZE@ITl;E}S%r{I2KL*nq{B(WixXB6caaFqj`8n55D!_yYCxwWi3k@TLB6E!$E&$; zvK8?h8Y1&+efG)Qg@ljt_aRcUH>$JNPg(lS)IGMtwmdIiO7v94s;O;4lgC^@Ef5lP z(2V$nkbHgNJORt5JztPHJS<^8EVIygIeG!5T{ggvHp~V63o@7m3-vplll}CNM>OX} zv6)cg6T07eiS}Q$GD*>^7FkMjVV79$wE;$r#2yhwf$W=`yGdP zxR8@ZiOm^h2@lV$Pz%r`Ee;Pw$}bC^A#Y)>v`fYGWO<+V4_kGxVg6)9J`?BLx7Jk6 zc9Us53#J1Mz_GMINBtuD+WtZ<2oq*^04phQk-4%=xtjZ&5$=dLqW3vtXO?#&l~MR! zlYHCq0lFJ`+fpTN=iaU5;mGQ0)2`QTyM(3eu2p5rfNG05ZHR&tan+Oi+mEpoeA#1n zH57eY?Y8pR3FmA4XPp|NIlq1~v}Ych_&l**SD#SFab!Ce^N=E%fvyJ&s}lHVMHS`T zG`GIxM(XGxet*?EsSxe(_!qkNbf2xL{!O;!$hUf%mnBziqbJ&pi_U7uCHge(r5G#_ zkN-nPP+}fNPSh_lZQPWSWi+McBH!X9`SPw8ifWW(Uy78NU*{_&KzFmedr1N*wX2QK z?;lD(X^ee;7DL|~?-?i~BYp?85n6D47wLL=&;mdH%!2IK9%LC)_qfh< zmR$B=zRj|nY-~vvO{sNz5l%e){bKb?n%rlcw%@RIk=?E+m$ztXT`Ooo?TgI~SDjJG z#S#MBlvl0t5Z^4hNZ0d{RTayvdF^mb;aiDA(QKw$=S5ST^jq01q-7ailJJl0)S=$C z)c30(zsatjv%O0(0?Lo!T3>QyIfsSTA@+>l-#iDM$m>j`S4KKcUbm51xxBE~(M-)= zL$Cm=2Mt-5uR6P9vM%BPUvPESu}j3noKDQjiMux+orq{C+GHu?3`1!qr4(?`WN?h8 z*@_hNZ4|VRe+OYuda?6RhY)`0i+g@D9;BOkBwS_EvluBI~t3M4s~bT!#ay>w!gWyH~r_+5Ns)*byRXprqV&tFc3N;hVZ>?4znOErT6E;l`l z-TFWwDVjm8iEUeB+K{%Y5)-pu&XAvNGFV3ZG5U-7yq6(do-+C1F6pCzk=9+$n(qv^ zdgq6ueTNtz$xniJI!fb6`^;>1ZG())GaEh46r1U&y>?n6^Ldg!u$2^Lb~%8&54M6o zJ4e~Xyy)E5bp3uj_30s_g6AS9O%E0bWD;Mfp$fjIw%fTifOST_cQ{j=A4s%Q%!|ci z4K0ip=TD_d zk`|3dUA~i;7+F1ot@$f{a`@8o4^RM3kEueG@9C9zu4?)TYb#{ZJ zYdgiFrc}6tEo#R#O4|zHIIqr@d<8Kdlg!ifUc;Ii)!P1$gji}>l9IkuX5db-o!aP9 zsctT~%;YND{7yQ|Vq=|a*i(nhl&5(+C$ci;EkS3M%kYlPgN!?%pAA7sC#2@-c?*1w zoe!QCmY|iHbNv22r2Sw4CSbZLpiluxr+zAK#IfB-ON(AF^ZM2K!va&{cBf0pw8)lj zsn(giB`E|2McjJX~92p*W~P{xo@8 z21d^hOPEqAq?~x{3ZIz$0o+K^+Srt{xPQXq4Az>2?LrWlSHz-?o~LGa%9@9!pKr{k zA`;A7uy(m?7R649Gp5LrI-Lmeo68v`uNAtMHx~PP>Ml?UrHfj0UU~(%!y!!boq=J- zF=;qdKfXA0`|U(|`XgMD2a?A*ZLcIk>@uybP#XUjbeknS)HnE^`^c+1y2jqtysas6I41&A=+VZ#|1E_=3aM0(NgdZ9jzMUZ z`i~(wn!whLX~vVbhRFQ1ZyUFCml4+VqA1IM%5RjO) zF6UAZNQK3MGj@#Z+jpSbO3>Bt(0n27Xt8HKMJ!AlFQ1;8K2x2u4#xr|iitnE@yEMb zY4Pk$(Xn#$uIhMn(w(u`4oXrj39jcqT5)01;n)M65BAzWV27VKeu z?fH&nQ=!5tb-W|<$4Cw5&WCk}Avt5l$vP>7GzW4rg4VM)0Wua7gaH*xofPA|*f`vR zUn`&MJ5riL+*V(xei8zH$NzZ^*xrcWr2uUNz2#b=HI91{-vZ8Wlk!*QzR<)FQK?%0 zjfU1_X6?-uZd_HPs_oBAPU&#u*v=I13@G_26?lQbqZZamin~bzW!SVBIr*-%qbS~+ zx#RjuX{rE2hDw>$n?{8H`{TseM4R9kCr#>yATCyl^T3^8`;ihxwC#iXat`J(D}M4J zS@=(HuL169qO#wCf{nSB3|M>@eQgrtw;jap`R)baE%ukiaPR7$cnTgd9$s*6cm>2u zkA$sUn;})uV@qQuN*HO-2ro+nmMIP&P^psQDf*u)1-|OBw=W;N=XW*0z`-_u(>CZ_ ztiyoa{j4#fsYEWni*;DFvp4;vFTlEC6f9V$&zoh=q}TZQ)MpfNKnNF}05@P7)?X<3 z?#5ej^JY{Yy(vUaQ$-5LMrp9{pxzS|zv>-BFE-^pQuUS;KYi z+92OjDu;oE^L8KmxMZ8hL|et8Hl19w_?b5a$1+rPIENtF>ca|$2++!XHf zAFJl7MrDq~H|g4xv8BeTNZ zh)4i;Q8aZjvXdAnaahp2Bz7*pgq|22+h{fp4$ENS7U@_!ysBp(^V`X%Sc|PK3KU$= zUZrFpQnh9Sb?hBMx60t@kQi{gn|V$-B!+sA7xH!?X8PhHesk$p=rmk+^x~+^U!m^u znWucwBq&YYJ4n?BP--ntskJC#ZCtK3K5*;cFd&c5Bc+$VnVAHmr#NhX*8 zG~|?psaIY6h)3d$MtyD$ci@K3b(0HcORzT=`Su*`_tFa%)vQ3*NI8;TH zD$Xk;g!KJYS5BPiG0Z>3zmnD70V@W1XTjq%c=aCd(8+4CD-h5HIo*Fbf6V?N)L;EMEdL?{=pyX7US> zymy-co8tS}6<@P^RckA}PJd2oFV(y zz`dAYs65+*;Y`-Iv1~j-v{^S5`yM043|MBGezviLmYHS>H|dyFrgbrk`r^&pyK7g) zmzrcapjUcPcWVRdBYY1f#vwOdHc?J>;A8$Rm2-2zvpUtud%`QhX(u@Tpq3YYD=Yw#iS z^|xEONg+#58B@Kx{!MOd%z`60WynTKoOGmCePk(C&QV(VQR~4?NxE2zQ*XfL0f(Fe zaI}pwC0QCJCBE}rly=+DsTcn>3-IsSekzWN5N!2G?}Sb6k9j|5VwpYwT%L77g*r%<@drHIGbM)D_dpM6qP)`9b6 z+20(bS4DW7k^aVbgEaB={e^3RZ>|At)Wu}2Mq}p;7i~Ve@?CEHd3D=by*^j5XE^bF zSmqg}X_D!GPc17iQ10nwv1B-za$*=;*%2x4CY?{^F5@KQ#M4Mr`?GM2=G=v0Dom`q zvbMRc<8E^h@_2Rx9|ZE7FrpNV0wa!{C9J*e`1G)#^N)XiNsXR=Vy`8u?Xl4IzMD*&e#3!kDChu|V;BpG+~wimwXD7Q zGEuS5*tEI6q=0b|^oH_&wE>_c;b&wS|LK8}+o-rHWKx*NpAb`pO(3_XUcVSAKcHxA za+ONQ*>J8Bn9!pTNgeDkeraCD3}s+6Hm3SLxK_!&3nWL$qVkEzW1DT|+_tE1cVRuI z=9;ekF%(GSikJGu#ts^VpFUvQ!V!FA7RkjdCy%yInXGkr*4NcQ7lyk<)G_uHYB5Ru!;7 zCGl`ss%7WX_VF|vtCj-upRE#Do4ok7w9;FyHrALLbcFSu%c*&N)@y?)zg7@XlG`5Qt z)wXh>!NIcTy9H&2rBds1b=zVVAOqqD2@9uE>ug(e>l`kalHYe$3VJI#r11E^B!O*I z9lGIXx7*ahAw;1aNeAraSZF9`HqaLr!|aPbPbfAml&KJtk^kD;|N9BP9Jogi<&BE2 zDql0eN8u!%EGP@}@KG|hW4UO|x{F&Pi?kVgWUJo3vo0g8b?Rkv?OI&iP!#XKhxJUA z-@UsU^wWiF4~nuhT@zvCp}k>=bX1nF^El?=T#n)C4k9jltkx5ml!wlfzBfK{6W|&{ zRZccw@rLq&^XUIWJ-8_Kki4CJNLge|h`YYi_4FH6Qj-xGKQrq^DqSN{eM8|`SHr=t zRAWJ53WMfA;(4*Xgn#8UGE`0*CPBQxy!G|;SQn`D;8j@6Vxrcbbr)8w@pt3P_C zDrQ6G+nULsH>K-M+gq)h!Y+1ZaR+36VmW@eMGuJs-;S*-)#f+m-=&P}tvB9*nqp!c z@9>{Lk6FQUgu~5V#8HW9?KDoZk_|q-oUAp)>I6X6OMakO4Eay*>Ck!zplE0Rq?rPY z?42HYLYbZCGwRN`4ZJkfsdPyx&Hcl}=HgfL2nBwzmUf9cH%Rq2ov_{Y5pF}#OA#xOz-EqVf)#BJ+f}QH{!BreQ7LKXNqC;Tfg++-S0)**y0i85(+8yfyZ8Kg{r!)v1?P#N9wm53$h74uBgRe1rzu+w zC@7qR<(MFy1QLv?^A17#{Ij6K1fM0Z^5yMo=$4$%>rzwT7A9~FvMKsHo{F1pG&Gb0 zF>I5J+WSC}#wzUvQ`==b+A)R|ZEo!gk&%tx_&q|!)C4Ww^GdR2$>SzBw{L>DGVMZtdUcrU{mWb$znCh63Ww9 zY%X!A8b2J(Eq1QNb89lFcl8ORq{ML|`zNRVyNWWoQ1P>Twz4y3J5}k-+WD-(BhrkP z!Q331zTc1^4oNv?IePnKTP_I0)YcuDge&Y}KH2T#@edy}y>Q+#4_JI`*oJ-Km~FyT z+=qyoomxoQP6#m}(aMywRIsL!QFh^T&(GcCNT^6F!yORMjt``wLbm-Y*mpl#xOKPd zPX*^GD`hudSMjsXiGH?+a^-ZN1P5YEP>ul7&5363Y=ZcI31ga1QDH0;&%<>$ct$H+ zIvTzXYz(`5Ak?^-@5BQBC36+IcNm$YsW34C&mFNTN_w^MYqO{~G|B6{%Y>Qem2JXkg%X397euy zJ2s$23;FO&HM-{J%V(_HLvFF1gdf5fz>4xYi&D!U_{4&J-#N5g1g@W#Y;v+~Ujpm- zMhq;QcaLwNRWW!{9r9E`@m_v@vgV?S%!+E2)!8XDLN%ILHvi*7y=fc)! zEjk2nod5IkQ~y)Xz3%;(@E8kM`uMfG3`5e|xRxrPUejcCP|y?)Mf}u8h$U{ zQ&!J-Y;$`ZLrb(pCoa+4#9ZMP`f2hm0s}klRlA1pS{M|lmJ1T4glux2sp8l?mI*X8 zlbU(uc1qm#9iBf%3Npz@%DJ>2ge@h4eY=ywJ$Lo4I#o};|H?|eL0nvbo5BN}74k0O zYF^mW!~07>5ZliAzdHM4l->(?-SXuUFlFZMoLI@{KWZE@GadZAIHaa4a1c|W%~{h) zFE&3%ZO{%bmoH*TY>3$I+*0&VS6V=^9C$OcfUg`ZefjL9Ie?X>Jz`zNtec_#HKu5p ze5%NN=PY0IvmoVBUUcp{uC`1XV$8&?C4t`UJ+{#ukj2CuNU@rjinU*4BVT43A)7`JjU!&(gz!!$TF4(v;!-hXuHC_8>C+|LXOrBY#ku*+LVC z1n*ZzoRSF!@@O_j&00jy;imm}Hj1hM+ga*4YDloA5%-vR*%^TiX07xnV*Z~RVtU?~ zo%JEz>0;C`6g3B+o)($fN%19frzE&m1=2>lJ{cPWIC$VfH(1lQxZz>JN95e^nX;W^ zw+xWqr8IVp5Ih(z7FWN|xm=}qrhVehf^X#%Jn~E%(@K3b4 zkFUlRIGod(B!6lL!rU!Gd9P`DcHZhP!jOgz4YU+Q#ZCg<`D($|Gj{Y zR4Ekxe~uMaoc+|GMADukW{OF(mahLte12onqAKYI@7#}%NWK02!Jg?u*Akphf--<> z0hBt!4B>spdHCKqs;W7&jHhZC-^86KsVx?<(ctYT9Js?y=?hC{;po|xyah0sEb!lG z%`3GjW?(NTfE`NlTo5UvD64wZv|Ha^S@N?k`*D}4o6*oS)!UoN^Z-})W`Sw{AGu&& zot1jrYMI_y2stN*3HAS809X4T1k=ZFM1l+5h|bS^_72^isz2^4-N`uQd-|@Hh`rUV@|QN$B0pRs><*zgXW< zk6FZ4%W_G7xKh)Sg|aKO-j}KI+ed`LcR@YuT2d)p27K8&Eq0KW*JZ^7Cjyb{laZ7f zmcAceENMC(p9umL@c>bgZtv`p(5r9u*oR@RncTbcm1Z|S>LacM0so~0@S6mr1>#D0 zTUjV`vgqYURF3gim?^g(Dp51%Lk8PS?W*jY$>zmP1RuXl#=ETgJ)?>1X!Gq3 zpYAU|EC7aGyPn!3XGcd%ZsCDpF5}TnDC|E0I8~{ znQyz3`Y*dlM_BPS0Q{xF|6pbNR=d#>@BP5^KQ*&Z1$_m7T=U|W;@G;_2^*~p0joY+ z!ZboV$FYPj?e;1}q$^VrWGgkwf!#bH(0b+MC=~LkJqZla$e8+JtIrCwFU@FYRF)UD z-x&x!w!}6H!x^TGTUkngV@K}KGcc3ynh;fGP2i>uG?jfK`49S4mGw_Hc}~2hMTG8; zIlXqA#{g9dU9*Tb6R(>d0ZPQl6L-oXS~_QR?mAB3yMcG$j|bMV=+eH|T+zJF^ZnPWtxY!cP`(qiIHM<)vQv ztsPQ4dWA9+Y-d@o9#`=k2X8``edjdNYBT>-Ip=6-@ST%Dw1@%A74(y?@2>h^mo0L@?gXu&<5v1jrFAM= z+{8(U^hz)A_dB~m-Mhg5rE9_PKX|AU>xF9ii89T78+r#fy_hwuIjm8)i%NNhgpyz; zMgeEmBf{;&gPlosaP5zC<`xg_)j#dlr%n1`1|ZP^eu%rnTdHIiM0KLXFhwoGl?f4d ze-YTk0;@3oChvPP)$@%G>`(qvbE_U>|3as%1}#)oQBsU}WlG;{44UP?cR4Hq1c6ti z03o`2*nJxfhQJKVE+BO^sG<+(wjTO;xp&%WHgd#w;Y=;H6hEJSP9-*0!8JhL56dVS zFo%S?ev1KOr@pCW(^vs%Njas`+y16=7aHy5_}JVcM^I2V1zfr%ZJ59)D*fV+WkcKODpzgPJD@aaU}Y8 z!P0({MSJ^$R_+e6B`GCJLcsN zJqPFWTOJk^DV7)`ri$(Pm^HYJ@#Dr=+jOIAM70frC;e~sE_cmQ2`YBMZ>N@do=L)b zo(|Q|E7yKi&YOeXuzu4@HUu;PoO}lIub&p|zQJ>xZ>sjH8gHlCm6TLpq@#U@lV}de zz}C5O(IN2lWtcjlk%>huBPJiw;E+CDZQ@+x>n8`%r;UPM=pc|F4CXY*uEdix1c`*X z6S1lr7o2?>tYQC=5x_cfky7?#&Wy(cP-%J4b|`M`k18uy8r zFqm@sYC?L=*0589*@joIdN8u0*11}vJ4N-hBfZAwBwMC3)%LZWMum|^K>>u6tfFO?oHWZTC6#b zR9cKV%G7LND^0UM-YG=7sUH7aWrA;D>3uor!cZ_^h21-*OqoRI+O4Ut#{>i-Huv`2 zoxTh=;63L9i+Jy|R$vgo%E1AjJ88%xnuE9683r4A3x4uh0=~(@bvvSEMxod(D%1<24Yb@&#Pw-y0p|r6VqSHTOCHn&Qop7vye2n zZrpS-BI2&yBmt5Xm6esf8@@ECx_vVP!yi(XLuu&xuZAeAc5B#Y>uLw-198KVFBnMJ zBFrpPyh@UwO@v>>hFWuFUf6GJq}Q|fE*!MYN~O|mH)*Q?gfZQ=|CS9>c7?Sv9Pm-U z?dGv`a1K09d>d)e?8zfske4rI6qnENTG&^;EM1ISbl>^ZKv75y1=Kj?`^>qWUDF@l zotS@Rc+{H(3^Sm9nb=AGcKuso*jF&5K3Db7m}L37_ifC`CwMo)jbu@0B=|?JRND2( zPh0T!=JeydgGab#-7QVc@Z=y2s{Y(i93$xm({_COvyxvQ`RSQX=BsR)SI;%m8L{}t z;(;oP%gXj$0j1SFy+7i7Aro!J59bUv<4#hgdpv5t*zmjJpyg!50bHXF2nSfu!M7e> zcY<7R0Ycm${ll9AR3p`&s*b>Ya=NV3b)FxNY&2yvV1B-6)o4=7TsgEB?gW)rOf*Ox zb>@6u4uCOJ3aKW0XwL;=BX$aH99kLi$KwsC-;S2-t6B0UhOvyPi0bfavFc4c2v*<* zu)cV0@j?m_^Oz4MV0F*GKP)hJYl!^}0%;zNj&p7!e6)>`J4DQ|_o~h)Zm;{LkoT!S z61_f-$GmVHqlR0suvD!@r_D!3u{O^rlW~g@3z{@SI8W%o?Wpz^ZTPW~Nc=-$K1)Gg z9m&AA^EKP;!0>T(GiT9X!2#b$etKV=iCO6T%jN0m>GRH{t$)axD}MXM%W8E(WO}pO zc8Cms$o9zv>|3n!;a!Vg74yVtw*!hOOf6J^ZAP0~TU)I^&;RMqff9io-0u46?HnIe z2RMsgNV&e6Tqu-&Q;Y6Xq`cW+>o1$}IqLQ$2|0o&$Ic5*=_qt{P)qXG|YESki8; z(sD=tqGd{Ct`_+y&xLoo69lR@HZWMY<*Hte(gJTAgG@M3i7Mb}gNjBsFP;DoB3$As zMk1;Gw(pv1F?bQi_`n&l89s%Cln%YNsHkzBpXj0|Ao@$!Mt_y@)8RXUpQ+T_O3xgk~>QpX+a zZ3PW#b$+c5bQ6;m55Dt#Dc4WeI8ne^q&{Zl%l%8ibetv`?ta|&IP)?-D3cWUeU;~Y z@-j7_r3}EYK$ToggLCTSl22!?;;f?H>yEp@`;{_$Qr8rrcz_9dXQM}d_UoGpZSly$ z`HX{68GP+Jjp`ObO+Bp!jLkynO3GfBqo1Qnd1OvdM}K#6v@GP}PTK9VS~JeH zN~gdZN~`#}SKAn`bmitwCP1l~KH!_`(G?Y^f<0f!UR8I9{##Lr(k@i|2S<0d{tM8f1le#N|S}k{3l? z1JktlsLCjAL%c1~8pYym{>M%@8vo+U!E+i{oUz>s)Fn-lmt zs%1puj+~xxCtB1g=S`E0DvpId7Rgp35aHdrA|;!tu#0dVV#LE^B3Xw(jpoIwiZ|wT zG12nhun44p6x}9O1o#*phls$u)gKfLD$MU6j1t|ajr`XvfYSW4?dR^dz&S1Q6JgPx z-#kGBS%gc|IJ!M30Ni!<9|IDbj`zIlERK!ua$=7qa#J=z!NUMEo=Kvq1}V zKr*8jRVca37HsA#J8NIR)Jl6&G=R$h031Uzi_N}0#TZEOxIim^BC9or^9iZ07x=yQ zxDt$ZXK(Ze{{Z>O+gC5odX&cMwrs*Q=m z&N63;SoMLn@3u@CRYiq0$H6bRW>2{vkcv8_<`Ys77m@c;p@DPC8hYE{Z8v9np7g77 z!JK16HFaC|$jwm6q2R~6GR4QG@!Q`xCV4!kN0S<0xw|uKm>&{WAnE%Yx&qteP$cat zf$^U#+N}nRzO_TlKwM$$khW`JZ|HrONCBRN(^aO>y3emKeR#awU0u(b0dOv2-;O+Y z*8+pv)9}CwQ@srD*+S>A!kEt{`Yxas> zAJfAlFf~d6iMOgZBV=Fl6~}3pG=)+S0W#;%lslCZ&ZYNQ;5V)|WKG#{@H=&u3p)E= zfDM5R2!%}L){NktZ@oc;NU0T1CaBD!h7Z|`A`j&VIJTp_w*waLDt(~UB3#g|R+)*Q zuQM|OZy#34kTxj%$ROhSXy1A^t$lD4MxXj7q1ku+=vTMzRUqewER%rW@$3ekbZ7GO zVmfeRFE6jjv9U4jezW6zGw#NUPrvVEW)JO|JiuxAT=#a4n?epXVg_7HZ26ctr}nTW zzAwK-6JP3-=|eq^es)GP-XRF-33L2s2$_o-9cTf7RF(MaOe|qKsJU$DJvLz?5?wH~UwEGP=fo*rLly#goROpFP*^#yeroPmJ2@geu$| zT; z>?4W5D{;u}aE0=I#R|YQrF@VW8c}t7hNfx@$OtW3zreXPNi1e?eaRKEUw>QCe>at( z8lW)Rnh%vf?sQ=Z1l7Ni1P;HWj`PIhK>$bb zn%G~B1&Q<0Y)gg|2nqA@o=ytF|7P#RNEFC%IvIb$pfC}ED?X)R+uc~+Vau(OZxYvN zwB-*_hr9F;%ZbPRwknENyf@aTx}5teH8B_9hU>MgJCu|I-}#4XS;@6hQs0xp%PYXD z{eKwmj?-3-*`ip6a-(p%{exkx{H!N*Xp?b+n*TIYpqZ-dF?6;0#ac1%&(5wmtRc%= zhcL1Qfqg0vC}v@^4F3MIv&ED9YuiyB--)n_`QXd69nz7Edgjed!-ChfJ49*b&93FG zxp6yF7da)za1SMalS7T`NnoybmufpDBnIWRHwVBbU`@dcIQqaJy2L1z`XU< zLf&2UHa9mfd(0TQ$6e#s0z6XB#Y;jEQ#Dg)dcx_7OQidSkHSUo%%i`;WT@1fICva! z7?K_AkvbWh`T}6V{`T>L98iPPWBVnK24`PW%>`lQ&rDYh5WI?dDU(!Li`n6QqC^?XJ z9{-s#O>E0`Y8p7EUaYfoZ$brJFsCjMaBYNb+M&C_#PZS80OjkdY$==*_0A=i55;b- z5VK?%bo1jDiaRW9mP5+&lcmv{SR#ShxwE*-A6G z9EOGkM7Y|O5eS%3;ARP1a2wgKWQWVJGNFVYfm!t&ZnTcdnIO-u%SFVf@68F4XN(v` zxE^m{uTWcG558s=puWBAg~XqpZBsc|;KLkyDe-$|9|NOL>h_K6@l!DOWy-@`T7ZRM zbL*va7iHZ}-=;b|xL5{?YKbqE^h3`8!#|03Q#m=x0xIhwKqb=8>wxD#<`GL5ZSYZ_ z^Xl)d%5n4Y4M44`y!W8u4fQYO}?$U^>l^uT4@*mSY69woHhMt zdeylsN$6xoJEr#b6GP~N;XKZbo8iu*O5XGDd~UKYwwrwHVh+TtvtVN5wfY&eRR4%v z07GI5`3snL@}2jP7BH%6kfMw`I2+WY&&Xl4(a^~`M2Aco8cjV1cn<(gkpsGaSkL+C za5|=qqr#_qZrJmr_y?+~(cLKDjwmxiuOGg?H&OUWN}IVL&rO$P0k3sBeMV|`G6DIy z#k&HsK)5>#R0)1-PI)K2LeU2s(jgSa9a5C(Q+62c+bTg!(4Wx;5f zNe>dtGjM3FZ3O7K6{b-8cNex@T-TZkx9Zz%sC0MR=y9^K3moR5DeB^`rZ!4F@`t2D z2dFH8b*IKK`c{<*cml-dYLwo)u?tmV#t!e-FSq3ZfHypvBXPhHcpU%qBlY#q4N@B% z=MEs6JE7{&4W>|W&LR)&AX%WIIz&YyOdcpXXo!URs!xurq_~)aySiRh{qzSCadBIg zd#kzKhH{};Oq6?Y0NkK;N_iLuq*5&F5%g1S62^M*=mSDy8hnm~E89iacx6{cYI_dn zm^G)lq4I8L&X%#maWu3YaxAD;Ot5+Ct`1+3@aut3y)(nGO@}GDGgA5|U z?~V5*eRYs?^0?YBfKmyz0?)OH@&D{R*}l97{mu2Y7AMq8a^#>5;0J9{QBPNFpJeM~ z+;_NyvruIxLLfsAXmM`FBQ9*deX`Z30Kf<|INl(E11Ej_`#Axh)vJq74$0p#6g-uH z-XUb(vE~S4#(k>vMi(UulpH07J%htlp=Q%=7y{05T ze}gjP_Ii`m^A;DNXWIeNgx%>vi4OL4aGk^H;x~esC(L~Xh*T!$Cx#ps5oX+1dgDwKo6Ga6fEj8Cy=OQ!9R8Gj^=_atu>A zuv=AzoSok@B`IMzj`L6vr|T#CoKAG0juKL>H1X(#+*en>0(^Z}fr-xnz61@Qh4Wn3 z+XI(QyY=hRlrpu7@vx>@pX_x{8D`uaKhDW&pOH_<5&NR?WSWYU@2FYO09TwY;9Oy> zXP7Ii45a=Bl0(t`YwHJXF2%i#2p)sFx7$g88h<9%)zz85cxXr^Gme|2ym9^X{vY3U|jnc+l%2jQWB!#GzfH{lLAmi=6nxspY+_q*h)%U+2aYHH+` z9KN)=OLKF(EXy^ToBQ(qK?b6SQT%rvcl~{U006W(0kDxuK%<1yqIwR8uFUbG@oK{c zkTa+fHBLUTl{yOSqC7SW5s39t38Q!3Y}mfbth>2U5mhsk%iyjCJQ-2z>^#~3J;}n! z<0iS?%XlZ8y+;AqFR#v8DmaGDyLY*i6-DEMI^G`1BC^`n+C{U@&qzEs-U9E!-nlsg z-4U%d)%T+V$s0SGdmf9G4+~;3YWtcB#P~rb*&!ntch1@ zUQA3Y3iy9uyoW$be7;-YIAlT-b?LO12J*`Vq`eNPihdgd*kb!ZclA)|{J?e2(&H;s z$ooBQ;DC%yf<3&~xl70HF(&Ot?0c3ggLn_IBsm0?2*fk61;6^w+P$+3mT|tfUafp}rd1a)S{_;iiz<$J`jMB-jv{Mkw}Ocjq*Kw7on(5a!r*s}Nf0)0i9)!&J()KZ z;<8zFaS>=88|t*VzCQ>gL5kH>K+xvoi;ADdJNnqG#0XREXmJSN$fh`4xy~1kvSx9` zqdK@;-VQarXY?u$9O3h7UT2lIqfB1%(uN$mhL@XpE#-Mzf?l=uRka=N}{=W$kTU~_si7>{});_9QcgjZSy;$stfr(* zvwzd`Bz@5v`qF=?dW`V`fY0`W5v42K&q6|yzlZ33?LRYmzdL7^jVbrVo7m~Opi7+no13|BhdQb@%muKy(2nM&zt8Y zpyZSkP2)#FTULV!oVQedhj%2|h{>lG7hBpp=pQ{I%F9zqh}hrTyG{;PMtjKOB8J*y zMp`TN$%?e}6iLJbhsQ!KFPL3sVv@hUD(g}Wd_I|#nA;(LzYALzH1aFx>p|~WSDCaz zZHU-wqW1ZT0C!cY)EIFLIf_)Fd~DzwP7QYTc%6s){epv{NipK62j!W(3R2p;uP-UZt?)Fy~%%vcV%T>(4NnwW9LN!B~(Wy`(H^_Qr^{F^QR)XTMsByYe3I(40l$LgR`y=re4fov# z?@&83&}7$}U>kuRskgh|B1fKPqxIfJ| zNsPCMeX=7c@W_QcK_{6j@s85?!7=jL>8}qqMywM?c^@#w0l#0j=xw&iZ#za zKFW{>#wjX7Xi2cNGI4Xhp*dIHVf;z`B%qbc<_;(lgM@+7c`o*uP}%F=&U?lZ57lhI zPshG`tiA88hsLh2&p&mqI+hioTi8VmIqFe5)jr_DvjxxJHpYef~$XR4fshoaY; zSn`xDz>eD6&7*Dj?US0(^P`mD@Ij4xG8ZJp9lcb`Ulxb^%z9t=DN;zM%|&=@=nch( zKcT~HvibD0GAE!Bz3*yy5N(vwlp-5`k41g{V=UXAu0+e$22n`x@e64dE!S81y+u2k z-s*og2SfSVzhAh94;*P|2I!#z0?Hv@{deIasr$Z6^ zE*OE7#pgd}cis&sL@B3_pUzj+9Mn6yBYsJ!#SN350-Hn0A%awY?02>^)`A-_ZqFhd z(F>V;50D5|(e@Itb-OG1qeo2lLdo`e4d?tkbR*T92+Sw&jX*1O8k zbiD7ppqx8eM5Rm03Of`98Eq}pX`Pic>D=xIvn&soojq%iUFrVukKx)Vp`L*;h#h#; z=xcA^YdU??WNx&PRhjPnOxfA<>{rb}v!lBR$fV`1Op=QQjPcR9q`TMA;cRe-!ag`d zkdQIFw9og2#~bb6QE~zWE$kWnTycWI3RUPl>-5V1A6su77FE=>4<8!o5$PTfL^>3Z z5*$M580iqCTe=ZJ8bp+CkQiDCX%Ojd1YuCRK?J0~&GUPo@A~4MKja!NnRE7>75BQ= zz4m_3LmkU-FF^#00Za5BlW#zrIX$b_^oO5sMK2dmB^4>e%SX^`-x!>htA+Y9er!>{ zL?FaC;JrFwqE`YAQ?p|#Admh3U%9#8$vEJ-hA@rrdthuw{@V(rW%A>$C+XxE1Fub+ zWJ@sog^CsBd@k)Q>TxkNAij*t{N>iqS)`#3%Q2)Hi-@k3nc>chlw z25}>y`qSNX;arI=a}MH&wx{O!OuC&)zKzFtY8?fo=d5tr^#F6VPXA;7r#tS*7u=HO z+8FoyvaZj@>AcE5?Xv_;iU!%tgpe;YQS(*2DB6BPeb7;NEu;30_b-v6#Fz5hH5QJl zTe>GdGe7LWSI@!8RQJ}{JR|-gup#wH#e(i|#m1UrQVI{OP{> zN7n1>i^j}S(O-na(eJZeQzM7M;!lHAp=!*>*kr6e6P65Oo2~HtRG+|6Qj?jq@c`bm zN20|$Fy9ydjqgn=na^zJreUT|zUo1?uk!A1n<~4DmY5e#fB59yI(YsFR%f)dVPom~ z+#d!bcJt+@)-cHWQ(Ivo#aX7_ta)^?>*060!2m3N`in>W2A~_LjGR||9||{P!;$d5q^&j6`a;^(pP3+wTiy{Aih*`U1TfcB_VtK5+=rgvidgr z1w%P37k5N3yunH8?Hvc}@zII=mS-1gB6W+VKMNW-xe8;;zZdnX$JI)2KJ~)tc=bPV z-s!vrdhW*$>Rpc_o_u-a=;Ht)J>5w))77D{eIJSsp8Hyv#rhd{yK0$A zL)k{@qX}fFCjF!|Tj}rA|6}&#&sbv2!AT(8wPxi%f>Qp#Q7+lt{*TDI;*Pc%9W2V* z<;d|?suP{c(4pE1+Ngg>D+_BoRdFr>@hMWi2yglSznS0sCig0wMHPWaQUBP1p(-N7 zlDp>V@Qy$%6`!VEvE&sd_v#aVs^DJ87W;Fy!>8V+*R7T1K@i<9A^Q;s=GwBJ*J**`_C8ssmk-B+~CRa|zcAc5Wcj(qX3%gp=6igs|swMM;D)qYmo5zPZS7|=vpOoi|~8NrR%uV<*1xR%9q4 z&lKN;pr=<4@!hxK5AyCL-x?Ld7G-$HiAjW`PSWOv_b?)ofcY-f!i3fzV^(Bmobhbd zxUr@~ZXklM&xYyd(?8Vn{}wg6N}ags*U=|XSJ^cbXl5@v2RmY@m2fI;UG3b8nr^@< zFX&z6z7Jyh*A`Rh5Gyx%&2!&E()PKo=M>qw7kX#kDe0AGWRqUYShsUa#E~D+Z&nh& z8I7!D!@GUelNqJ@mq?_^YmE6TyXUwm!D67vY|@{HtjCSeNIlPcmoSG17I&p|z>)3$ z>#H;9#-p9&kj~@{0nlRvOKx(rs`C0{cX~la_?J#ocI>=6MYv>`WZ8CsDX&pj>uGy+ z!SCnk3*Mha9+0+Haui+~K6G~;EdVyVAz*y4CghgcY6Q*ud7E)RD2EP4GJ$Rn#XS6b~j4Pnv!w8z7&0N zee2Dj6oU;zMp2~L{bVkbR*oiKJDFQ^-Tj8+sap`%&cX`&JI#g9d8uc&B$}Iv4Gflw zN(ff+vs@-$qzj^Mt2u}}f8B9scrCsDR8;E<6oxlt!aZ?-YcOlA=K9YcjV4y!^HdUY z`vQN-@8V3IcH&;(e!Wp+VKeWvlzH3{m}&VRZ;YkXHnV}J3ig~X^s~1=xD?5=bn*?7 zllH$0+`w-N=4M*7-HzS#mVBR7xr;p{9{%`NnBH$k0-v8|h0EFe; z6;u56BV&hi*XU8Xl*^34Lcp@9mwqZJW41R$FV;4`-MF9|w~`-$fq-DK|L6v*fVW~k zM6nWf-+9nH?OkPW-HM(3(}{0Yw}?JUCUXq`W?R!EN|L;nfIT2!8NxW>$zEe5AnC1I zC{l{q>i6lvibGeA=c`#g>8i>RoF|6(FaN6rxXC{M%Zr%bF^7;De*Y23@XTvVx3D0! zMZYM^DjrYCjSmdg712q(_c+>wseLiEyHL!*5eiHE zgry`x?R}#i2T~7Q!?XYi2KW zzdSg4UR|hIr`e-wWwP)37j0j0Wpn(f{VcS*FJdpyF*vt=M2-xmf$@}^GHQFSu42G_ z{$Cj&+_Oh;j_~ZJ%VX^48*0q1uPzTjBgEbBVu>k^s)3zWw7mIvHgYsiA#7`NlVrap zNxDjRK+<{f@pt!mXCtHeb~XP^ErxFCNYL3RABo~?(FJNU_L`a+ytK45^3d`K+*n=i zRm>t=J3GPx<&?3ZlYyU6on{tL9|^rES?kf10V!s86ED+|Yn3kd&xHl!a#U?oQ&Y^| z$z}hyP{NE`V$gnb*x8pFyJ>kKTB9y5 zE5ivIvdZ#**aAA@*(2`6ZZ`{S`a7lsj*}L>$Hy0UxEFdhJ6mJN+UT(pSzS}J0c71{ z1OeByl_Z087>~WZPhYRDuHH$qWzDsx_hw|i9YJsH=(x-6rt)cbM2rV2y8usDqVQ&u zPUr?NYo;};>K`j7)}{)kw5}C9&n0DX+IM1NH!T6p_mt z&4CNzwuwRxlJ6Tk3GtHYz!Cv9QH z>XWZvGVu6tr-ddl`WSg~_$zfJDXqDOzMTnGypG&519fD9pb7iyh8Q2etvUw6yp`cbAnf>h>1Jazaif4+V z;DM4{VvrUj6eEMYhioOF3gTZX08^-7a0>J#2P6cVINE@CT4Br0vm4 z`Q3${3$7}`I1LuRcePMufbhVaF)Q%f!pP>!g z&7TUTF)4%6XNQ-&WiW62Z1MLMrHw%e6wK>44pV>O!Vk`XGRfnQz%8fkeqD;Km6a+q zTRp#`hVqc^p9L(-QL&nnFW|R7Pc_6V=6CCo8=?8?a_mY^l||`|(Kmc=wi;Z`tV&mp z4EtuZC1kg1Ro8B3gta^M!0Ts-EQ`h@GhHuiOp z1EOq(paVdFI>O_Vnc;7u1&|Sl+&@RtCZ4#&v>`)7s#K5p{m=fk9X7R5j~AdCk?vo@ zo1n!9uphrfhwKIi2Sb}*sYyG}yH6u0r>0_`vfW-nyyR_=VlBbY#wY^ngETD-jTabS z!R;`f`C^Q~;lAJ_P>$I94XE8@VVf~Z-2Slxx7OXRySF0*Vwcj30Pmeo^7!<4XD+5@ zia1oZz&Eqi2x$P>>r9qGnaD{iAn&QEuN)43^pW81q z{rFnG6&~*4;nC2tS694=0X3G^RM40kd(c}Y2w0lCx(ZdCJ^kXWuYaD3y5gZHaawGxC`acBSlYAQrok~y50BQI zgrW4yP!#Z2|I!m{9&URZ(?6bzU#kXJYbft`)z|yuA81I1ofj>j1r6Zv-|iMG{Dhj* zKC&RHbkzs36AP9UM-F3nTC?SUP*8r-UHrrZKL=&;E{*>I|1MEgGTyqMTZV8Jem0R2 z2UVOz`#ptA2tHqTa*?HZ7yj-^YYD5N?;mHs&53xit+n21(#0pz+x5@)K?vLRtmxPK zADB+5{oCPyiFrNVfZ$>oOXwBj&v$B7CJvx!>A_LMMBt1Wbl9b{6qTebx@*3(hwr>P zE%F%Sg6WW2YUyX7OdP{K7sc2 z&dN4de;nwFJO^<(tycj2pL5VdIazH;1bG?s`C@x36RWSTVml9;;vrQ`XPSnGQ`5zp z&WlC8@0a+y*h*{W5X;0+tFzHb6^Pr!_fEe=Y?M?~WI06Ft0O2O>hb1?+JYofI=o6P zq-S`Ks@+b7_7SP?@%9g|lX*`j~$}}eBs%> zJF~5}lPJmVR;$mt+|R+ZgMLP@0PxMmn^BV~`!lY5@N2rbZZ)B<^g`5OY0U$;;B6y2 z=rcR~HL8ULejd#HuW_rP)!xbJ`Uv0|{l94hpS_*-2DgYIT3XtI$et8v!6KR_2bG8M z^04y{bI=a-5S+Wd1$snBH{Hy;a}ALyPo8wVgc4!|2uF@ig)|W;3?k&@-||QAQgJHD z+X7&xbU|4l*8fEzON=-DC+l4bwU%zb`RPf9Vetl|HBY4-Pz?oNz8tnk6ZjD}U;6b=jeSzG`dThd(U!7K5YAv3 zO@p2NT&r}l|8eelO(j)ZR#}a-E4|ev=(U{>T3K+0cberr5*Ozv-t;pc@PiT{ZA@x{ zY#p5;?MX@KII@QwYQ+y;Bn9_y<-1ohlp!1afR2u?P}go4d!H9#MY?MzqD4qCy`CUN zXv`TwYH!sCtv{|GQ%L(nZau-`(o*~PsLU5-l1~xxNA0?YG?4Vo6WL0AU>9lW>dZ}9 za`UzzTzPnZ1W%___aJLyL5wzrWN&-Bw5O6(8;Pv>c!qmqf3<_<|4912$WP}EiEfu6 zTz<4mx(4bIcAGWSthYabX-XC3k803B2J*nS6yg{!&DhO%V0VX))`bcPxjHVX7jVDq`BsiA^wYUJr5tZh^dH2nqxC z{gB^O2w>gP(wdqFdBjiQV^eeW)4JRbQO(W%Edr}X>$HV&f?Y(zuec}5Q5AIN#yI=^ zv9Yn;PCz*-MXGMn=;5%ul4Qd8ajxU&A3;~>R{cgPpNq56 z#;AJIuW>_r+LJHl*D!Fc4%A(sxe<5X#Uv=8$#rdr*c0vV`Ls1)92O%Vrf;X_X>?); zxw^WmmYf?Nks#+e;DJ=}CT?zS7X_`EXWI z&^H~X%~#r9DJJFtu7rh5;cL>2ksiZuV2y5txQ zApbpal<=2E@Uvh@)c=#KBO0n95 z8=c5d^u20&cXv`ol&^~9fOU%Zc^JdR9}_G1@8$9{rsis<>1$zcrVy096m@y}N-f)a z%OAKt;p8I}%iqZg;q7D`xz!WX)5EUxYrM!oxA71z_>!PRffmw4VLJG1;WNr-@Foh0 zPBGBZ(OF^3IFkzEAH(7+QCF=Wxpc00@g5wfSPT19gexOet{sdJwIQ-OC9cgfT_=~M zs7($bZ{i5WZ~DF}1uPV!)@}u;00cr+s7qOM(%7L|GW7;o5_cv0Ch-^?cNj`}RDn3a z8qAz*SLQDggad7kL=EcF%y7czpo=OHUVQDQN>8_{c4ua0M0xOoE0{afd0_%j7jws2 z9?D>Z!ZMy5{%1Di7WvhKaTO}1MDAjuu#W38REqsye(0+D)k@!c{AULedkC1A4w8>7 zo)h&5u4c4{yN?-^RlKRDQ*L-_pLsSIzUF-bC4c-)T-4p`G+S&dG~Cb#*(KVw|2 z{6F`DS`$BhENedKqMKg(S9SRNY^#DvM?=F!AV{gKhE?vRAD_r*;S&sd%}HV)0#>@Y zX;-EbPtf4JUusugU(irq?%0AdE=E~-;OQK9P9O<`lE6{21fh(Mj*bi7O<2z)zuSQF zqVR3UFRs#>!d$zHcX8zQco_C~;+g8p&yRrq@>5Ap&I{C%=!veK5W$lk4+$JoB}z0W zx^D{9&L;CoIfP+|ywMH#E1IAv#LFU7tPENW#(`MILHgfkj6;91985LFW6-j#(zRD4 z!?r5f^u+)VB?9U@Z!m#*gD@J^qm#sEa;0c;l0uRDopohn>?8yTk2)82cV>Ml+ImWIu%IE2pHKqPRuhLWGNmP$KB)y$^IFq;~ z1!cFiTs?_hGECa=T-x3H6<4ZTWoBVA@sV`Y`tN*KGP|7v`yLYu)Pa1EN7&+a-6D^L zZ>76hlG4=rNu&EXL)_Ci!-n6y zDCZcPBK=-)uQ~(EyZaEyg5h%3qp!tTn)w0mfmY?<@9`T^o0nZw(P9N$h0~@x33I;H zl0|#piT`~1pZ;ZvxXxAbo!@TgGo!nJS@4}X;8PT|v-Y9l@pja?>DMca8dj%=7p<30 zZ|K@dr%o7CJk)i4QIJCPX{D~Bwt&dFuTId-l+D9T(721p8-slL!B_l+KgkM5-xx7e zq5bmSH?Fjc*pSKjxxmy+M1VLlh+IRX{#N9wER~Elt!$2ZOpS-z32smeC1t!J57~Sn z%Bj9mI$n|Jc6`pf4zXI=lIRm(9ewmxfNBC_?D|4yG7+dk)3X$`CRv~^3r)hHc^NXI zXD34db7icE4sjq}w&(0u4jg9`6rP{>Sdw8MN)Dfgpm_O?A6K}|J9iN0!Q*>?9PPUp z>Ji1MNrj*?G7Ljh;0{4^_sL}p)FK&h@h>cnBn#Ahpl>z$M9jM2mM+t*kGdG#E(+L z5_lOUt+^C6b~`&e_{?&@4|D;}yTNqvwRof@1OW$r> zeB*;S1+-ec*#xGm+(9h@{N!hiS!d)Tp2FpE7+7 zRP@MZMP!BSwEQoY$Hv_p<)|TS{&DyDet-0J%~pe_bAqnpce5&IfWC~7*J6xuS&`)~ zPmQNPc*J=tK*@XOSMQnecP}pY0Z@nd4LbM6&~_4S_hb9Z4{ zNiBrbi?WQcT>%XfSiB6hij~Y1*L@8tyuC7j^IYq9Iqnc#cIlrpr3COoL8um1$|(OT z5Ks1_l0P%UnY1RWXfnTSV~?Z^?r|y6OeVcO21$%+r^R{rkQZYgaAE@e)CpVJO{~Nk zlS0TN;o-w_tV$mcex-LiuKs*Gxpc)aoW5*!Kx7VE<%qxZyBCB5{{z^wvm|FxKVMhZ zLsxH}Wzo-i6;Sqrgd>8?H6!cj`XPN^*^_f`lR=?q@^}|uidG7Fqgi1cztWU`rBecK zoxxy~WD01P%Ef^sIK7YA9#fpax$qvp4nh@!pDOZ2kaq4?{#iV_VL3d@+=Bj-XsKKA zW4VN^wKv0iy*Aem`+M zT3=cl(8lU=L>Ag2F#7&@IuQL0^wQ|jC(;gO{(&S|ZLvy-Z;&dk zvy(8bzcNz#%T3vQx+$HMJG!oJK5!e^Jfkm;8aZ+kzq9T(3&szIu=a~(sSy5oIXS<~ zV5cw1u)oN$X|DT#exQ8GrbYQy=G@50ej_vf^HRMpmgYHIn|7INF_iJEVDksD>Jdq% z2C4%Szy_%~SzCX#nnWQz*Gl%3xs98=WZ7*OEb4{C7&4hbNTme=8Q2!cW7-ckcAl;Ff8Lwf0S$6x((KZH6UI!tcKYu0#$)GOJ zw^VQaBnt$Tmc8qXk2=PBV_6`$oV^P-#4&X%=zbLI8o*82SOy$vnNm5bhhqK(eG21= zu2UA+I8{l_o!%-UJUyhmZA6xi*tA-}avRWat-p@voY0!BP~}GwdzS}Z7eZJs7%P+AP3&_= z^?ezGX8rK;vbiKm0c0SBbX!1pR&YIP>rdqItpawy)RlaY!pH?r_`z>A)EzGnr3*$_DZb0WJto~uO{235ifi69PW3cJvw+8TQdiP^td0q5YJbG)Uz)AKru4Jb!v#JK!iFajkQIsD=9zL9k>LEv+u|S=-7v0#V^$oPv;rC8}g9FyT)hW8Z z@H|+h^YhH5;$&BH6zB)c95#U}u3D|)pRr=go(U%$tv=cRr>Xc#(Y=creb|(%zu$7B z1P0)BRgvwF5j2h3{JUEjYNkoN*u2ygMbO}wlItv#8bK0_GN z04-2PAOd#JuRh|G=IMZ&&}-R~HZ8l%MHz>~Z#h5eV}sfJk=Q$=wzfn6DXOY+iCptG z0i+Ne{Le7NPT5e92UtYAoo+vLpaf+U#gbi3@>2k26v9Xt&Wgf+;+v90GPiBqD;KbA zL0x$QJkd(okZfpcZ||oDih_qh{e+@Z^u%pZ@_8ma>erkA6#r`36H$Q2@1Zukqufi2 zQF}BZjz6!3A3Qjkt6J^*$d!fj4rv{RQe9tP7ZvNidsJ^N{74(QX62KPYN5w&@Az@- z+d&M}tnztTz`7Y>VtAdfMYm*>2W7)sSzX}j1E6vi2@=OhkNu7B{K!80s+s5x(3vys zoL@=CE(x=?VyG$*V0~?KpRWK{<+-O$jFq`}t^>+KCw;pL#PmX?Y$~*P*s_NPf01k` zStPJJ2hGO$cE5xnd3sN&kj>f%c}tg{3Hm{IDy~XF#hA0zfQFo_P?Rr;3Q#K$FW6}Z z9VwqDM5yuPp&weh=i*A2NB+F$;f1O*4o>ua?xbAz`jymjK zZE88sn#=fXlzq}N>5?%AzcgKqdA6X!S*RKjUKH}LCD2=fu@$e)@6N-v#dqEZN4TNf z|J4F`UEqPrICS>qdl>rU8W4`#fn_yWvcE*0j6kpM1tAbu21u%G(W}drPrEUzOt|}| zz&I&F%Q>bch8v$Ebx^hf_Q}o|hO{yAOp%>tI7ULAH?5x2mOy_|`T%W2IfiDwAypP9 zaN@*Q*(3~Ud*_4<-QM(VzH)5MC3?#WWvB&k6r#zxtl;RxgfM8+o;OIx4PiTPC#ZF@ zU@krbcXcwJb@lh1EY*0HSEh&{ZA`iLBp!z0>)%9wHf4}&Ad7V;@f!7UaTl=A#u)$d zUf|O{vV^6?!cCI+;H>oBdZ1r67+QXp3cz_Y8M0E|w^ao-Qxx$^z*$)dsd%}q)yLl7 zRk{z5c6GP}3U&Q4&mD#e)X*n@Kxeo`-?h_ zPpAp5nw1d+x^{av$)R+lhDf+&uYxyMtnbM;ln+gi6Cgp-LwtAU8&59@Z z@AWI)cWS1eQ=1R<{~jp=G`YkY4zVe~$e#o(#e!_Qn_U@;Hi=-TwR`7llnse>V!Z8g{;0%H^Ukh4X2Q1vYp zcd;vdLMI_`kveVr^HFTX8+C?VZ{u^S2B`7l*4rBu5W$ATQ?RAoa%l6tm923gf6EzA zku(BjvYvk2!M98jH(aCE5E@f3L@+w)Var2HF2Ytr2b~x{yj8%Yu-`N_7LgYC1t?oi z17@GRWK#8*T6*b?n>Rr6K?#q(%E?=qo|mx}$UO{Pu5^iFmp!Dv9X5(rE}2HUZwC)O z&Nz3Sr3tD{{oHhMX0VAmKPXA_SwsuNG@gP*(?TLx$-0Z0u@BktgPpuGJR^-xp(&o!dBbe;>i=NH|b~byOlK*5c{2mMDuXyUkJzw z_uW`*$c7IS_3TBZhp%qRs8d$jGonUL$?&y}3_)CzpYk`$6G;Mc7z`paJfi zwC2r0(eTc!<|cu~#KfS5x_*+FMl$M0>C?)h&TguSAHam^GS~bUT+-t_!oaz2FfohS zYMZ#c$L50t1qEM#6rcm;gB2tYTEt=i7epYFM1+Nb7@o(!-F@DqXp`^t=a60~IgL4c zM9-X$!4s*;iHUR+D&e!}UvJot4~N0H_duGrjXq@#>XV&*^#KM+p#c~T$RT|C2~Y>Z zFmFW!uR?r_bTb7k<#t@R&S^_)^+?U~P2SaFPFcWbKnH@dhTll zr1SFAL(3`(JkqbPzgl2yn^p-TCx_mcrDArB)KIT07M!oGGZ)rf32N(eY>wwDHcMX@ zJK!DYD_ISlMTiC~6%y@^Qns}}QE;GTyK$ZiebcMp5e+H=W!=S^sh=@8hboZagFN;X zhc9mn>ZY!K7&!oa?^9!$?DPN@_@nRNvZcb!NO3vlk5gQ`O$0(wQJwPMnI~U% zOH!Hq13nh1d1rd6C@6GKV<_{u@ic1_FG|zV(zd+4Z4x~{GxJpyiDrd&`mC&r0n3s` zXDql+8?o3qdI@I;Gg^T11Ps6Cx^*`gDPVFSWNS_mYK6;Yy~e!wAB$Wr}zo|>9E z1npxW88p`QeO{op8sflwa#22JQKSm!YY_%7YC-H&4T;9oEbtSbOwVUIgq-rfoM*mF zW#*thoy~Vso0o(3xk?UU@7uj|^zC?HdAe%%2V>>)qb+04Wdu9!Uao)By<_G9YY|v<1?dM>>2t3Id5^ zx~zstJ_09A^Lk`r!Ywhp6c%qr%vJt;k^-bnR{|r&K#!wI9UY+P2!qm%Fmp;+qXj(W z^G3OR&L4LrGeKUG<4dJA<*4I^Yd^s*!%(6QC+!+7M9a9sU1H(eAZcn$DrrBc)4A@4 zAsKATnn4-*!Ys#5?ERYeHK1UdKLyCp!)WqRS%3Yn`|lmN>H~BMExv*}eN;gm>J5ru zY=C4w#WmtbSerK73U=QbxM<9JW#zY0znRTVy@Z`{IFb4V%3ysbKvpIhhQuXyUiH!M6I@6zxb)cgLs(o#?`y+41mpAf{IgD*M zfh7NMx45?*4)NRR>gjho%^X>hbs_9!Fy*`h-iH@4x7ecN&^CWEfr-n;SWB} zvya)qBPVP_K7N|<{VM<$R|+{%qOn(G+Y)6>O&7beR2rLz{QZRX<_QMJ^Ha5eKM9{` zwI{63IzYEY{(d#XnG9fmgS)*1V&1+)JT6Fut?T>qs=woCJRwklnt@M=^Ne)JE6S6 zAhnNm^ge^rdLizJxbr02$Q+q+WKvt*79_k+h**}z2iz0QI~a7fsO0lBfJ3styiCGp zv3|Z%%rH^d%1f+Z>aQW`=UYqr3gUd=yu=%cXxETHpt5b_UW(Dpcmx2>E%=5rFlC1K z3unt~4Nua)-B!S4*&esW?&G9}=eBNe`#t>1UfewJ;OpV<_qtdgYFh23!=1|2E>Dvy zQs8(PeIu{qKVu!flvMq&ronao>ziz>76Ox3im_Cd5CuVn(WPw+RIzVdo3Os@OY`kK zv3e&_od2#2w)0PKNPN3H@=S2@%k)vj(=pch3BHGRU`|1_WiJaz_6jlblNJrVv_Qqy z&@>_k1%X-z<11ZTxa_nnRvhr-GSszxsgCz9F;bE!?*wCEp2lb>=&elDR%U7lKpljBO+AOe-rHB^; zXwkadZj*3o77!QJwK#1ZDfEEM`T7jO%XO??_nMtCE>Ht8#OcN$%zxS<-KF2y5b|w7 zfyUgC%MEs^0W>GYIHC-i3?yMXMuC?{94D(X03JgJ+_7ouK)vGVbT&kVlvF?pktFL( znuv&q?CnLR;@%!Ubi}CyY*)S#vM`Q8+yj}jwJ)(J1u?D1tyM=0aGbZ8yjrWiLFmvB zpBn>>m!~l84swPGSi(w}!8#^@j|LW_ZK&NECWNGLg$6Ly-<0FbD(rqv%)3e= zAxBz|B{qwYu3iWDqujZzGtNj(wZ*cyB*|Yyl)()iGT?I|b3`a3K zfpL+kuX4yD;Wtt6SkvA8R~HTnbH8eB9#}Xl`c7*xgy+5ousy6~ z(@KsljVsepYjPewM!>8-c?thKt}i}Dt#udv!p;FFNJCHR%D`Lg#xF~rm)+ML0obyG zL;GkPu9>mbO&R|fS%)=)mo10DgfgaPpTjg@H1=g>lrRN^EQHc_C;De2!OU&=btL7X z!|W5trzT}Z7x7X&s2Md&kgbI*3$HHPbTl7uc57ZRE#ndD(#9&61w@TTdU44W@X{|Wt9;kI=tF5kUg{^Fzh4TOxvA0XjF{*1?b^i zMN)7@!gb$`f$wEc4sEY22AMXZc160Xi#?1?)R3O2P5f?``^X3RC<@3H!8SK-Rv+$H z3tF^h(>v&X6Xa30fMsEgN9&Sk8H%4v$5};=L;g)z3vS#dVsg z0jaEXQeJodb|=R6op^uLEF7M(e%2&uT{e$nS}9;j*1)G*jBz(IHVg2h{~^qw%Yl=gB2V2j)G_r8dz|b?r=c80i=o?NGLO4O0XN0ZX^ zdoAZA%bPBTE28Ful;+V`*^-h{oz5)A(h{d|z z7xd$a>bZk7f)=m8c&;62AlI(jq=)N;5<)A34q%}#yVnSN$> zo@XM*FLcYe`(*&3nLj?XWtgbq)6 zA3ED&R#Nb+=_(`6p>5V&Y#~z9)!Xp=-PYtgpUibC)uaS-FX*PHSYGVaC zSc{RGSuWsZ3!FCJw&aD${u|Mxn)VsoniYFD)tp?w37V277}47JXgS2(`z+5}Y!yxz z)k9ckBZ75;)VxlXj}hIcmsdZ4%GyIDZZ7R(QWCm-;MrkU?@21pKvP)wW<(F>NP0RI zZ?!{X7TGtfY{h3kbnpcv#H==GEg229wcQltSGb`xXqzQhp3_3&)Dsu-Pp%J7Axx>y zdO>YI@92g=pp7d{+igC6{K!}D{Zo4&^+A8Kphe#jjXiH2ahqdxy109NLFd35q0#+! zDWqIHJpMDngG?S_56S@jt|uF=lv7y{dTHE0kWMB$p~Oj!m|!v)68ek5PrF-gbHtKs zr!wxBBYqM4KxJIGOl0dv$xLd24K&j5cx>$JEq*=atbYM8GivH2KWZ8?T(*KY{IrX3 z$hbNXW7UzB1Jh?9a~l!uL7B^FWVB{WUbv^m7(}-)6Un~S-rc49C(3=Y_eK5GT{89d64D8|LwnIrT7w7d@oP(ZzirMl{4_Bw z3`o%RQ^GiX+f6^%^njQt70b+oHBoo$w!|J@ibyvG=)hU-ialz^ceLO5s~UZjp+1wB?x6&=iy zzAi)vQ}KkBmow+?AgYIIcT{Q}TGe%R|7p0ox|+Xu@uH(Ao^{J=ICI`|ZM~nbwaM#y zIII(7b1Xsvf`T8l{(?cQBV+Vtchb>bgo{yTp~8W=Ki6v`#QAN;^3Q)cVio!iT*Q-^ zm5}|m=>NQ6x@rlZT{}G8oNI(VIz5>OipP&Lwe!RL(G^}2nF*ghU}4bXX1B$BPd)|x zoB*mI!g*2Sf&rk$;D#aLM_F^2`z%5gRI(Cw)Lnv{7J1WK4u|KN59|^J+&3qrphytw zt*GA~xWga4PbqB6NJA@7QBb5neTJgX3pz1MS#q19DgHx3%{~T1bwze|Hpb%)z&4-o z7&rY}Bn)T(-&j<$@g9q8_)_vIk!7YLf_z*hJZ1Rk{4!1+9{+KJQK`QPhmbPFeokt< zYza2XZn@Aiut&&}`CfDC)UTyEBZCh7xLq+VKJvMADgT2BmNPy~MZW)1znB(jXxP+K zmc<%pk5vid8LTjl>t0fdii%2$D9Wj?_eL)?`_kR$=eyp#0o#~)?42ELMTL@SrJNG6 ze0PCJa7OqB{oaBi?iEB;f~}==WS7dhtX%HNU=3CB>fs+}zM$p^O;%G|Q=E zx1q|E6ch8+I06EK;~L{@$QmE_&A}V0YStZFwSvTm8@YMZP1_SSUN~=9~e(L zRsZT*F7|4oNJ9qFX>=}Cjv^nOeK{Wd;O&bhvbb(fU=#8c(*|*gCk0)m{MHP2PCDz^ zgTPjzM$g2a;tR0MhlG$x-&{QpT%9T3x766 zuoCBW;%0)%c=`uLwyU(1@rGUYT!w)Q0U_y)1Fz@LI7PfAGGp3JE9XP!r-pxwjcI0m zc!e#bMkzZ`?cjSKMy&Xd%CmXY+f(ED^M~YZ78?&~*mBDTE^Y}))+ zTg4+rsvGyl*h$CO+d>04-K`*vT8FHc)LkMYe`YmpsNI8>CH`8xe!N%(~Y;%=mm*66_I)EN-+O*o6{mII5-$=ka1yW zEyj_BpOor%m+o+ZXtDAB0RY;bU{Ffa(qrN~r`R!dD_R;%FqA&05vRL<|314CXSa9^ zxojzr32^COH;Pw$!jJzQTg?mf(&DM%t1L&Uf2ZuexFi1B10jH5*6_3_z+BC)s{;`` z*NAapNoyNboMkqk{A2`wHSFxIvh@xqTX=$k2)oCGK^ozC;zrJ)GVW7*>69I~FJpec zfOfDE%^N9l+C?_XM#gmOsnFx(*u=C?=h-XGF4wn+gAE?I5B!xADrOA9DDVhS7%m0S zoFndW#Bj#9mKG^2bjl19X<+qvRJT2~7a}wCN0)zjbk2V=N`crD)Aaks-Tr^@@FcGG z6&f4e$qN%m`usN+CwA9Po!4I5nPiz^ca6#`_8m1ZfFx9ZNF7Lc{lUcU@Lvc9&eyY$ z+L#QV_ubPIziL#Db^`e3JjaQ?F%IOfh~hG3-)U42db3yMv}JiJ^wTbD?zYKtJ5#G9 z$&)Cdy3+l^6Aw4%G@@qOII8@nb4RHXrXNXX0mfMer|mmj&x+k^CrFOgI z1Cqjbz>tKBR`f1mvJR%#9Dj5ncfUSO1fsT(Hp~9yNO&UpfEJ2NDc}T+d{LI~y->in zfu&#B;j!Tl@mY*r8~XW#PFQlye>Hgz(?Of`U2clcP7grjW@s+-;CTHw8<$5Uv5-ga z&OdgTB+n;9#t4Lz$^(zq*1ait+&#>%HMzNFKq4&^zHWzbyDdbjB%;Rhoim_Snk)kG zZG(|Z3xv8|gzca#8?5Yu=7QPW@n=B&sTkp~5q>&mZ>@#AW`ze?fD0j5zJDC>)Ajj* zg>ah!`<^ixes%u!*)8rPhKr>!N65dK+8wG8GA_U?PXF{;icuknZSS4lMlDZEzJ6>P z^5Iqa3u>$U!$)^>e?h!h&L1}gs<-z6#GI?WTyl#WA4>{L5;|<<>GzR;b}IAdX!6tL zf_NSLRKIBxRYJna&7H1_XuX?TOcL3mCZ{A6;TRKMtHdcHcsK_ctFYa+13c;S`XkmK z>#sshHRBUcPo439%LIcPC=fef-c7BPMP=&7ym2kn@{F}dVYshK6?LQq9hd&r z`-tWT`L_xX?W0aD-y`CT30Xa=kH~|q)iJCuum{mijuTu34HFU+egANCt?mhipi0XG zFOY!dqx;x?)mjF~cpEwP3-%c}5Ofq7d#0lw92q5{B2{^X34Y6cwfK>F89}K@Izm%# zImCH$Fm@fF=R$hC#z?~r+JXB+U)Q(VHseFxJ058D9>6Vw=pEY0 zWgz?00YBv=rj>YR$DIN4!h4FhBnpAoSPYd=PYOVo$3Aysa`old){oZJIOjk%HF`uw-cQC3u zWyBrj%O4ymFnVn5>bmiZO{`_wKbtC7it=D-iF<*A{64K2 z3thnvsew&urOOl_npiEUA#23;bOET#9g*jPZn8}DfBKWN#~YlsHj%qHEfRYp{fGtm z*AdQKd7c!IxcCSp76F=Q&)6!bm_vxA_pUb(-S?33)fa8j3%45H#aYslays36IB&V8&smGmQ6p(j-$(a%n5cEK25|wct1yjcAeR?kB8g)EcY1{QAqEh4t49lk z&WKd>ItmDU?(%g{KVf@kJ72e$F*S{sMZ%adDY~bR@x2&{`0(MwZ83K>djseRzzv4L z8nyW7DaMbiN4Lb5vQ}X8CV13#(Fte?3+#4)uUeNAVtt4K>7jvqU4k|=qh#L=8PIkF z-^#G@57+e^Wy7_KXEaoEiIki>HUD9yz6w}ue$gKPNfz= z#JnuX!po+3v$1iO$~(|kxR8Q?W%a?Y{yX13e9~tG&!e{KOU_p%tj&e9%d~BtevvMdWQ$L0L=}J-Jfjnf;vL1OfE-kaW&<>S^rO z&Pa(NTP*46OV1O}C&=aMSc-SHPCt8hK=GscF*{t?jhT_;iV*%%L$oehB(`E?WQ5-M z_T$YBaPLK-His8IXprIEfY)4Bul?QL1OM6anf{?pvkwo;G|=&ZV!1l!HLpUK0b zkzNJGoY6N{S68{k_Dq)EN7Ev1gA=&zD*N1-4*VQ~@JL#)2k1JaioC1m5^^hf}Z?B2g1yMkW>REKNE52v_xeLAwD zN<8=Ft?tdAJTiM%170-!07;Ov)x#*xhTI>3qF_X{Fjn%?-adJ7IX znC#>~HQ*|&nu@ekjb11I@cp+Hq)&_=QkdTQHY)y+0s|r!Msg*BYhtrOUhmN((dTNu z^jT8O@(`j1|9yYcR%S(II6@bhB&H=_-$O^|%`gB-_oRL`#M+U2Ut0N>u zzLLP6V69Pc?`L^MdATop&)!FK+>v%r!Y@$n)Ig`7RkiCx;|iG|9ss~JNxuK`^Jr0HuX9V4SZ+bsb1KEIX+R_Hw6Z|(Q4ZxlgSnw!6AK$n+uJt2P+ep;<7XYa> zo`a;H;o-g+C(o`Wib%tIuc# zo$2xCe*oM4e*l{u=cRAurNOF49b;A&sbB%I@;+c4(TTs}N_vNeZRdJ&$Y++HFIu*v zN!8~Ly!Ssvwi`PxdmwFL@jQYb{%o0yuR7rJeNb9G(>CSyEB_Zn%jM!1?%^#*Z8ay> zT=~7Rz%;n8Rmpz6besvxm{3z5dN)j&xcFPY?NHj`x1AMu!2KJwECi-~5F#&-165*c za7Y_U<-SEG-mJ@VaN9b*cc~ypPC@Wm%a7>&E#N0RP`OKbV21D5Z)VOm7_Gr{Uyie7 z1t2)0ku6^zqa0F*$>>Kfg~{zJx{-92;uD9>k_AFj6T;e6rk2(o`2GUom{J zEt0ERV*47P3dW*m0jUBEFK1SE=UB;!j5}vq6h11&>aUGw%S~Z}Ed05I#@-^hMgj1)WK?OXYl{+Oh7s3tZ=Y1j zIKIj`Znphb?;HMaDyuF|I!`4xLpUvZo8*_)>T4c zi;_9icz1+&cznjkJ}e)E^?mmS6A=EmKYaL*!va^R7k?L73WpBWWMl&5uTaHN*1DET44c;Ke+tc+PI=YJvq>DWJv zdYXW#*p1vuv8kgYK#zy+7`^(}uU!iYxPv?l>oe0QiN5tD%^`fMDYk!h55o zk#$V)&x~=R7LxBp$AEbfm1`LvKWyItWGM^!mo;v(V#pk~0`S%CHXuDYo4;Kb7S)8< zi)?bd;4)mxD9sH#_BA;g(Pt;f`?5sXL(SQL4+45e15FSzrA`Ach~RF1=YmjHsJ!Fy zl_h!U+d(IwRDW8b)#P}RhAgVvj3rzucafGijnxww@@2@=(9;@?tG5J2DDq?|Z4UTf+{&5q=iji$JVpFEWl z8quebF4Dh_S^YqO(bYG3xzA5XNEl>}t0{3U%LiqE5|rY!I+Y3NF+;d4JiHW8e%$~l zbz4@*Y=QYKi-az!*Qs0Hpn_0?`;5@v6rKB%e~xz29kDSKed(kO!OpINeYvI&=$95q8*1MXRR@o`S$mY({W4sgp>iGYw^NXwBEK7$h*zWzi z%JS3wO`N|byD>h*gk6kO(z4U#;n+T{n<=~ZT--(UXN$0xDa5QD>vf!S86cj<{1D!S zX*ulu^S*U3BsI7{diVa!USxBVd0)=lLXfEo{=m1$#0GkV4OfY|1d+K`DQMFDHjAe} zEeOE?is0X-46Vyt6nc7ezV6)_GXrTL&%)F9!YiaUGg6MVilUbx*{xhS_@lA>TIYn~ z$*&G|aThyJuzu+!KsLfJa30LpNa1bY8jEDjum>0TOqn<{#rKXUKY=3}>mcqjyI629 zMiGc>yKfYj&`!47@GfE6@G#=x$$uE6=jM;EW7B1R0HS5$K0)ad0PBEi_5^g=ilOIs z{A$`K0bR71@gvp@H}2JvKs#yD_x-nX;bd9mz5SCIBs>9h+TLh}hJ6S6ra0r;$6skD zb+Aoq;PT`GMPaF!vEJfOunzYDa75MP@i114?9G(e9?Zqh&!;c$F@uQnGs^!DCLFm_KB|x7BNHR;a39wRjvyg=g z;r}*-`z4^*BqrA>83dlXFb6kD5fi%~hH;C;Mk>_Up}>?sGg&B=trW0Ua4<;PMmAE!D2H3q0FuTT)Iw z(5_VAX94eStGUEe6xJCfKHVTNI#MYiXvMCKB#`q}QzvnC2LG=WFM`rDu8D{0MTZ9L zz35gW%zucainc8}sl--+7!iVUVIx7#9Y4Nq@!Yw?1-dHpHoc@SPdD9FK6uxZ99rs2 zqsTnC_{_RY$w0?(Has7(x|scqa>mzLl(6BQkcsS0)Bt?sPHKqvp9VBEiqJm z&V6~9h6KNaa>HS3_hEa6z%QFqw@yAh0u8-jh4q*%c@heqh%af(;KI`g|50t3R%{+y zQGTrul~djE$hG;soi)NKsQK);&LNQO-|+q0cKw_Ugen*}R?a7d!pyP(feOb-xcmu-~g zxkV0QE0kTLM2rR{|!xmu7 zPVUpxeHFOQY+NLYTMx8Hzfw0hAM24ka(p&Lc*!}a6h88H&QPC>XOpWA^((Bt8)4>= zHZPu8lzaiohsUXLy`b{LJg@P_3pTD88h*?v4|@qMd4oS%M!ncp!^q=n>twy?%Z3MGf;l2FY_*)M7Begak(P z9;{QcLG_|5<=H2+0ILz<5DOY?fh2cY6KP>_0CVUu>i!fYQssRpchtpI9AJh{7iGL$ zoF`YGN*`=0@=qps2AMp*pBsq^@&jY3WF3vd07v#%tySGp8!oG`m;qq8%pJ9Iv5OWV z5O8+f1t*ok=2RKy-UNDqoub>9VCwhANMQ=p2x*6_1?`n3wc9}fON_gLQ?&x(t zt0#eiS2^A4N{TPfw!!rOYK({}oQ9x@iI9lmzr07+EzVHCcG2RYy^*4O)FgBSzn6Lj zhC^ATExW3#BfmN$nuEHhlqz4EBXL|V=!R*mJM zGy|z6K^Zp4Wj4L5qCfh%eC;j}YlxRTV6d96(~?WC;!lP3onkadY`l&8*!wwpdS3tazh5U)wE z=lev_K>97|Iwj%{cy~x6`|=$qK@vH~TjKia1DCxCK-;RhnsHn+ZO;mO!kV>=!@}Qi zum}QFgiPhei*#2xJ@U)#ETaTy zGHBN^k_p}Q_k0QBJ6s081@cO{9=oVu7%ze@+`sxIn=VzQ5M^n|8(Zym*t5Zl9lm^Gqs}2lT}XKI}S|UxHX@)Dw_h zWLlZzd=b@uG<(niZ*4O)V^($EKDen471H-{A#QU-BZa^c_W3od=T5^zSq%iBW)rFZ z89&Tnm`V?v`4cS}dKn-OK8A9%t&3Wxx5_bm_RLqmI?0xvoM^KsSqd*oE{?0f>{Fb-UMIm) z8!&Zmv3buWN?k1lHq{hp7VIs%qc&5>TwY|$0-M*>*FW3?1hJHRwmR6$RIyf+TtNX> z0r_G0Ynah}q%Z*5?-)p%>l{z+Lo9TCCt~$FyPdkohSw;2koSK%NY<>Y!4tI=r*9UZ zgi4d#(WlUtao55065n*}QQ05RSW@L3l8sB!>GIjEYL+beW0Ww2xI-eUV;1WP7+A|v zR|C7EWCGO8ZWbr3YQEM&1v`%~F{Y3!T7R37V$ROaZlTW6UzIsB-6jL4-TY@`EBz=% zo5#bqdan$;3@}%rB7dRC0BU)_B?4wZ+(oiv4Zq3p7W+P6BcAb9L)Ac)*FIlbg*dO2TLi zsP}&C2Pg92|I1-pnn$!9y^d0cGJ!8RjHD%Mc8;{Ldn^abGYMG9+VO*aT|kY|IQHnh z8rc4(Txsx&%UC+UBTx_C#-h;$jf=PM3UI z%2MVI62Ipy@M@Su_L;3JHLHW&VwtzCtcs`cVQd>-it}HH;BAU@WP%SGNmV6DIdkqj z)Kyf8vbj{?_mda>AhUY@_1m{}pog(KmxJ++k6LrC(>8ty~t#O;ifD)s3ENgCg-*0sC zbD0sZ{KRy_6Oi$VGW# z(|q(xaD9j<0pEu#gpydKK*FtCIIlQfNF^@I&uXVEK0~^*xe8|QJ!HhT`JoTX5VSC{ z=+BCEeJ5P4eI` zx}uN-S}H8EEX>VruaL`s{-)QVFuL*gLA_h{9p_I; zi%a0v`cc_Fn^JD=5|yNh7D&+%dKAr7kO<0*SEIZksEf>~WM(&!y>_VDONOh8k+WbC z?>Aq6E%h=AKq)NZO)CFolK`@iqUBSO;0DX8#?#~%9(0YYOv0LEg!7=e&%DWqy#o%mzO$5x@Ell@q z1DB?I0u5_+SJUUA<0JlUSG~6$OTDa&ZQ@tXej8Tp}x*Y-~4!K zDYLxKZ%xP>&?~tMz~{l@o~^~rj%Vt5^e$AyJ*azerzP<|)n1n6votUnNyR<;feLua zP2`W3M~ny-fGNMJ|40^s&wfPbvp9E%mddGueg%-HX_Iqgl)H}sF&JV`p)Zmux6$SM z2XBOE1WQqJ0>9*YcA)MWY{}84Bn<79hA9*SEV+nef2WyVvVrGP0y_K0JV4;2wtoZ~ zZTN^%uf6e*mGu&eYd4Ga?2d=f*tzk07!a;a0Egv`6N?0}`6d1m=fH2U@MzS%*2#(r zCJ0({NAlCrziQsU|K0SXEt)Jh`42qh4(}?4FYZQh@(KtP>M5f0xLu#F`^c5-e(FiD z(K-fezS8GTEWw-LWnt$j>&u)MyMk^OBv0@j4xyC+A!;hM$5|kwZG^%9?T}MryHzK! zc?c;!ie^+|EuW1NN_snewb!USH8Uf_$=d3xMAi3cBlozK!HUaVQev4}&H6>W${%iS zMEqfS03QO#O!RoLI6QK`&Q6<_UNccLy9do3vS9;7<`+ln!e%91@eLSi4Z6h{U5Jfm zIZi*#H|{yd??m3cmqWnr|4?&?Io92i_5Uu8#8A{KkJ27qf-1!FbGcQOC+1+#!J)$W zNQO%BniCS{WlHVk9`s8YM)S+o9|B~nlauh_fP6cvmz?f+kfOi*Yk384zKm-vzdk0I zeQa7B#l1+PawS({8~l8W^=_pIN-~dQD^z+t$@Z$6YvR7Y{Pk{PXnm9R-!!4_@3W5t zb?J^VFG-`N6rF=Y-3ab$I=}l;_hNRLU5vZ3X|wGznd&c;RabW@POIKjAZS#w3q@&p zAQJ!l`J-E$tR}VokawCTq87~5Y+h{C#k6TZe|}C6NHuUaobiX-iEa{u$ppe8d!wLm z|COS1o`#hvjzR4JN?RBwn*gp5cd=gEceWlEAnTm{8rIbh;`2g)|fG`l*OQJprE~M@Ou9#WVqP z-HP2N!|H$=)}Alpj_U{9LTBsv4Z<48W$p)Y&-KwdKTk~8CCCD(MiK4Fo(&qW{wUva zwRdJrDs9;bK#kR9BxOPr4B~(Sq<1x3<;Ag)`*@bj_IA_HI#9xs+l6<(EJ{|`Hqs^8 zy#l3Vn^W?F#_`BjKo&S%Pp#P3LMvk zG7_hNqHMl)y*&A2t{VcXdKwt7qXAdga7>BJJj#p-jEbx9gHQXi3NVqYSydCgSLMn< zCFt6c7+ls)!Bm&%s@4&E+k-icbFIgh?eB4blhdp*7YpT?$xRwx!Rc1OML83`f6kQ} z6`{%tCRy?e3*ROT)*QE#;6H%UNcj2$a7T#t;NrfWy4G6t;uE}v`!~I9gPm zlh|ZT8H$ojk5s-#VHf1&PRevxpBW^FzN=f)+S%zD$sRMlE!sy$-ADbaA0RF9T4ve- zqQ7@n@FtF=Ci;D&vAcL2M3A(X4@bveOrJt8(*I2LvC zZo4$1+l7YqM#0B7m`#__MEdHQZtWc{z~RL#@u+7TxFyZ_Ll&N2Orz*jGp38OiVA~e zD@B7Hc=0JcXb`P~PFxUKWMAg|I4>=TWO3*YBak-|a&89&D$b{O0y!_STQ-s(?uQ?& zhUaevCii^U3TVr3N;=~;#VOBgBHagpR1WFU0qXuL5-@F`H9N@=tPC)*#x=WTt5Cg9 zjZ$=Hz8SX8U(A{k9o8@xg@j}Aqyq(^^_6`;?$+#tRG6@6{R&%J7r58f3}kCqgK2#hk9Z zR}u{X!Ry96npv|xF@;CLwzem6cwy|hX?O^lF7-mUUQ&rbs=SCYKFkz?ddkdo}k|E zp{7v;7rY#4*5*=r)8YRs6dF9T_|7r7T7Ugtpi-D=4Di>DT(qEh|FP$J@{&9GkkjAL zs?rzY8k=_+Mt7F4`PyX;m68kfU&whqr4$>MMm%f(sXLQ*aFN0FKa+YfjjGe<%_PxWah#gs^PWnhkvN!VNl==JFU#S561+dx>Y zT)k)WSX=u&dCw4#p?ulAWSbNQJ_XpBZ)Us*6-!~W(3I4u|FjjLvwCq|xo%d{elcp+aosGBnvpfORDH?qVLRPOCi-L;w(2Z^VFMYAN`{9*ZM}zY8g#erH zuplEvkDt}$k!l}Y)jh@M3RTtAbly)#o=_fPaZeChxh)EFG zU5*ufN#!Fz!~xDN5&MuYr-JadwpJU$Tn16j88e zV>jsf^fSjQ_n&KI4k_TbY|5>0`u)acb6rbQoH=jvl?-wSz1hJ!Br+SZvZ27LJSrO8 z?NHG9s&q+Z%CTjji;f1L9dB;0ll0%1sIvR9?O)s)#a0f7$fp+%x`c^&_a9&lS-#s} zL(14nUp`Z7XcSY=4XFhmi)qG#XE%!j$nDw(8Gu`>UFV7=_Fxq{_B!ehrJfu|!eL%F zh^r3a1;NULp*I@Wp^Tac(*#U$&oQ7H5KuMGxfAeQ9-=1Yg`2!eEqG7k9cN3puJaR zqt8IMVhhqU8BLl;-@D}S8vbtF(gbmp?=q%0!;WB&d-!YyYh%#@+~{smtedaoBF9@2*C;t!d=;0^1tjJ-gQPYUr!$|Haj6 ziXkuc{HD!OXUCbeP=`9}qxk9x*Ud+-;+Ue1nV`)5^C>rVWCb0vx*zBN0R9 zck&Yf7_jc;&oxuAz_uMbc%wHG{8%}sOl7M+*v!VBe}ROb*;sKf%+_C$FN({le!(9J zuI~QPq8dVfZ(Y1|a_H>oQA4NjDV@j5NUy+F#x*;~0`x66@t*j1E~tQ{EjLA=Ju}eM zZTT#oWs%ikd#=?YoLZ&4UyV0ydTL5YLkb*DF%1mCQY8Sw9VLK(!FHYlP5EC^SZ5&( ze;AWfMhX8I7O?g^;LOzwd$JSLDhcRDC+QqK;*I#J3c@P`58V{P{erxxuoy7*zof0$ ztEdfx!`3A=ey1N@;}P%p($$aVDC>t!YB#%2y2YSwbHRtgt8~=n`^#C%%FD~sbSlIx z1*&UmgxwT4@1ajMJv|#gpFD}+&9#3H^wg~;Wgqi_&=-`7JGO*dSgqN=q|^q2ex)vc=n&*%Q+ zc%C0gLVx+!tw|mTr{5QGVZw`qtx!W}lRCmfABB}hPqOb|?`z7Ws+7Ngyx(6vJL6}o z>GZP0{Z>qu{L#~x<;e7^^hi&CX!3tYRL7mKbKx@cd&D%laV3flbs~f&&z@qP!o}MzJybMS|k%LTt=WQ$r5X4ySBgeL_kbzT#W%0Ly>~CfxytD zxl9B`lFk*e@D}45f5v_KEQd<2m0a8RCy)F=p{nnsQDyba;0cx)dj@Q3-Y;abpPN0Y zrd>xj49t7KZ8)(4I{T8B8-1oCWd;t(qKdiBR}njpNd#d^MaCosPoMe-AmkQmlcJh% z*y>_~Lkt*Fo&#PNjf9AMGq$!R#SyEeROO2u29l(&-~hZu{`HA>w_$`;S>lMoTWFxp zvxq~s*WK+(81a3DXc>5zG+_@^>WXZ5i@Hb(Nfv`M#0aM8IE{F+&!dvv6riE@-=v&G zR@d1CBOR~{c&5$Z3~Z-`cr=h-FW4tDBODkQIC2x}2hr+>Z1e&3qye-~=KAt}EK{Oz z_oC4Z-knt=j1(A7J}2y92_Y`nde+EX5gHhefb+C!Fe+&mx+OziUzDsCryOm=4V$^7 zT?!nr+!e9qo*LkUA`*mod1VH-0vZ+`s*&1)G66?r;P{W> zO7y$WJ|(%-<|QM+pvwvfeGcBRct#~2$Bs8ba9PIS!pu+L15g7TJFczPIim>q&am<) zK6n>w(C3{CdKQ^)*}-ldtv5RH+9ZxZ4_1Qk65^`Cu;pH*nIz#|Z2)BDO=3f}qeCOy zr)W=Tw!6HJHp1+0$n*b177ZN*&gq}T1usNtO9V?W`pKU+p2ABMTjrXF_=$RAR9&N1 z?xvICZrouaHoXoJ2<|vnX=J@$wD9qa4+#kcp$ew)`M#0l-EzjES1JO9#`RV>x1JhPp z`eVcAPGHU0O5d>$^{L&&0`+@Nq20<-MaM(`nE-3y`?U9eGpP3urN7!|ikaYi)|2eM zd$pZ`B}rmJg^GW$TUe2GjZId>S<(00d#1_DI*!Sw{W)D%(pjvy#^V!>U*MS_pPPpdbvr%UkSmy3mIf2WkR#-)i8(dV>G7 zl3ErxMmMCJ_f&hYA;lL|@6>(Vj^PUhEP%3FxtrsfCC+5?e*Lw*Yjn(Yq@2gIO$yuu zM>d_wW=y{R2_eaUECxO`4Tu_)hEeZ4*4J+iLTIrA^+hf$QB9evOwv_taDi$~BO55l zPAV)a#5CN?0Er<>o}?5-jClK|iIX9wT%2@Vx=%!s;M=x%JnAao`#-ly0`(P85wIt4 z3s0BpPJ&rGw}Ruy@;?B(Fent2b77N_GUNHiO^(iHRw>hNX3n|R!0U=>2q@&fA)zrE zG~GCNz2n8KS2f4XG^2CWp_Ic8$w#*eQ&|+#5N*E)6p1HJ7tCnV)eURw^Ed9&`_vkH zc(QoL8%hllcT*g5a9+ZjSui|DWU8?(vJ}Wy|)Z$gw)FV+92k&sQhD z&Xm6-I5tH@m`M>`A6NXAb&eiXD@^6@r)bIswA<;=4Xrme zYh8aFF21}hBVFlVlTKC~yg3`}?ZY?T?9`|HN6wU59=~M+E!whkLMxJ|{ZiMwIa5T9 zh#f5J8{F>qZjogQzSId^h-(mB_1}(p`X916b3~A@bQ*C_CW^n7Q^Q;Xk^!pV4YGU& zm;y67(aJ__jjfJaab`2>XbbG<8EQYr#h8kGQ%6qlL{y%RwMC%3YYdw;_Z9dHfrX|S zmbmgt2O0otPE;5oI zv*4(mW&O$C;J^CC#G^5<#P%o#WqPYJLmzF9&fq%iKlUfb@`M!i;)@e5*F;6nw;4@;Zcp~REiQIg3gT_to)PPRfL2`NVK^R3 z=_KfIoUhw?v-sj-R(T~$>o}=Hsr*~6V9V+Dc|yME0y4}K;p*d>(g>Ys&*A)gbydt> zL`xArvz$2N_cohJeJL~6YkD=@=7WesAGrY@ly0xz<9t#}p$x_k#cjkCr>PGB!KyBM z>C9W7e}(hEuWf6Zb^hCU{_9~w6{&ei=k?t;m7%Mx$B8fN#csE}yk|?56N%2xtHfl2 zk8j}Q;B03%`)2c|ESN@74DiuU?hwHeh^bg)n=fGqv&DSJ#y2u**cFcqA@whyt5hse z8~9q>c;>P+kjm;9^+RWqYls>!;I%fpa#v0l=+F{S-n?Ocd&RDIg=U(zTHdn@-gECf z_fETtmWTB`2>+yY*%S5xXA|_42heT4Mexx&^M4n==6!t(zoKsc-tpf~`7s~QX6<@F zUw1c{h9|j^U@N&Z8LvbT(L-&Oz9{nPj=3u3YqgFyMCm1L208|_pOrYb8ra&L@01Ey z+Uj0^$9b|X9m6L4c3WX*hYEW$ezpJb*lz#CH|KA@zN~DG;IlT_!>k=V)<|g?3VZ6A z8gTMrP;5c*2=`y#BRR%*uHyT?iek+*;VUMu@_ZuSjr{8*&fF+wtRkjx_ni~N{4qt} zo9p5|Ut3EScP6ll*|r%Icu#ihv3f0D&&NRS`Ej$Y|Mv$pwsC65sx3Z*dQ#41 zcmC>~=;_8E2&;t`&!!Af)qT9hUu2T7u z!pw-U$K&&0)7CUlG*r{2h|g7xdEgNM1+jm0*?_H_m1G1>0I1yPg0Bf>G*Ou8CIAk5 za1)wSFM78<^XB@EhAHR!g^z|pa2vlrDO0h*;X^Tk=`$uzpYlvwhEXd;2`sP2#8N92 zeHEj|z02R5evE0V*uDRKS~i9TFP4}EK^g$3wZdb%YI-FYY9q*s&*D*9r>so+c*C|g z>}H6@oO|EoP;=wp?$2?rEXApIirMPy$I)NyGbb(C&V|klUkZ6uJYw2(B4FSIKaJ%3 zXTqOcr+OxHYu~gMt3k(z4=h=yEZ{HrCCad=cmwEXw@BTQ1gIiuNFU_|1>+`${C7 zZouHx4yLK2Z+WnXnlbHw46jaC<$2Nnm>94KzQ`ZmlRF1C;y-Wk+>4_6`t_^El!1PW zOi(nG|G4;tAL2xBlq@dW$*En^-PEK?cwAiMBU_5YJ zDGd+#S0FfMuE7!~3T;rxo5YEFMY??DoopDhI)QOmhuVTetq@k1fznfrq4v_y;NA!ufz}v3$NX`A- z00Odk@~ZuYmBl>~s|9DKLEZ8STj?7Gz*AB4qAlLS49ApMe=Pus1fs6$?h}Bi2!mJb z4HTwdHMML>8U79Pb;4U(Ke-yrhY{z-^TC`{#K%PQ>{Q#r67w6!u{{>xw89xxqmroM zCnB^_Ort$dy(nhwp6f=~>JQNBSy1PKrXOP0_DV+ZcFdhl-aVD-fLlM*eP}tQ)|4pN zY13OS_IBr`4*Zyx#S40RxG*ETh2B&N_hz+yvTAzZYNJo{!EN9STX&W0#ODyW8PDYR zz$Q+xN>p8FB6su37#qWZhllg2uSbPL(0p@~8*ft{(G3@9#m2hgA)OM*#OeLqn4L}j z6Sf*2k=@nq%=D1gpNFglDYTr`{S4cP^2)4F%g6!iF8mXt*fCa0mzs@WNSi1N{_dhQ zG8x!iPM^Qlv{LjaJl){?mi57bGHFM54gZE?(e8ttAj9HYD90y(ROyyI642C(Ht?9# z@EGdEO5gtqLQYL^Bl3yIt$;LF^Mwf`}`Mp2i#PMyuURm=M|@o4Ya;3%d$PlS7zqii#PYg=|0O|$F)I?_5*EOX{e zgHN$d)*^c^pcOFj8WjWSQBRUEuDygug7IeW%ZiGMfCl(6&7(#$CJf4#FPK!$d;8t> z0wrh_ZM5N$UpNdFniMSW`p^gSL60x9n@69>E&MXzfx?3ZWX?~U@V;elz~cwR$Aqe~ z9cPiqTfrTQ({qjX`dq<$#a|vkm~`I?M=VEvbjug}g><|!7i3Un)bX_&eng!~IetWziwfe~r2;2nF*Dvn{@ z!|7(4z(#{6b;j>sl6iHToab65U4N4YtAQYJ4IctK1-gLNZPD6Fg;Nu{btSZS2J`}) zTetk*H%u`SxX4~}bO&?hN6X8sTP$#$$Oe9lCu@vO1!hB<5q1sqVslv64l+`8?1I4G z@@hV_x(a$`_Eqy?AL;F@wC-CeWgXa89a`lr-UYWYM$|Ag>c`w&aD;~8G|M-`h84fm zhswZ0uhj_3jVZd$Evh}ZM$06fNI!9-gceH1BUy4G`<&RT5#QVd)&aCeLMJ;q1_lrOdh;G?4r6ui{WpeJ%af zH93@h>iohS_UL;M)q##5%j?=Wdyz!1p41v4r6|@~dW+gGUEt6sm?#nk-mqqb$ki}0 z?eAB#gG3do+NTMF9!Mx(2=!Lq-So}iiUcBTHEt{QMPlp*74*G zMpEJGrn}$s`N>{3AWGwvpzjHrZ}(0x#VL6Ri{vMp$!5EE1gX=ngeymoW zh?bubiM4HV)QTNiDTL^#ACa~(x-#OOT{O8L9a-WCpt^G-%*IYSilR>>fR~R~kVtm= zi-Fd+R`)fMVm-WIw<=G1zwyeHpE)d?xwnT7iiKa=I)e45Bt0Ab85>}EbcV;p*`c%M zJ>yO=MMu*bXRA3Yvgd~p&%I{1csV)(n4xu{RD6+m?+ZL0xNHh)2=F_Vz$ghaRN0jz zs$MWjunEXwOg_8>>jcjf!L%+75vC4+uWG<{Mr$`UAI|^l$TXOgYMD*g^U-&MsrU;wUXgfzIrsYm zUCN1i5N>XR4TF7butwXe`m^?D{yX0By`K$Oz$sW9i&`g7TXg=joB<~GZIKa$%FV^f z0bm{rTs8*HxDOJ7r8Snt$+lQ8%>=|{2tK-1%AHBV`Dad7>l&uJt}FePSG=CV7(OO| z2uD*$6oxMnAkDgTg_Y8^;Kq6PF#g$6vw!2i8x+5TzYjluaY*5MeAAx)=Ks?IY_kaZ(l0kuw*pU{?8sfA z0jrAMXd}E-YHm&><%6DnxIOQz_S{ZWvGug6Tk9r8x!Z@?dA5J&ZK*}g#rD6WCl%`4 zih{C?!{r!b@7Fr7=W?CM3JM(eJvsGNk%LUte4#aU9=1O#?B$rxK301W3y0nQVvg4> zWW~M{@;XR8Y0qZ5T9kR|-Rm(pZLiq*HqT8xE3Xa<0mKe)Y_iNB@b|-R$vEaE*MaVi zJ>Tn(S1yO8OiMeeAPaRLP3-a%&!S{1xjXEu*n}5Mt(et#p0~|;U^I#?hT^MU;P|Tc zd8~5oB6Z38n5goSQ87{LeM^+jL~^^keuoRIL)t$IHqOV#$Ft`4=hyZc@!x~;fe&b) z?D|CbqRT!vZIL3y*?L}Jo9xN^NJQL4gVmto;4t$a^;{2AlMkSW`$kEono2Cwqm8{IKD<~W5zrWd(xJTMsMk?t zu~7h@-9iNYYfC=N$**isvgpc07F<6oC-#yAEaJ^z9-e<@Bc1rpuP!_f>DAakTI zlsfo6uqpaxq6xekLu5U_0ZHy8fA=U5!kZNAy8vcMZ};odsGl*a-RQ!Z*9r;>M!*Kh z0T}u(93p8WVJ~*%B}AmHCakG?5Y;I2f#)aT?Q=da6$5{eI9Zz7?&*hnD<<0 zyu4QX>8h`4u0+(+QnL(f9K!4Gn*ZiX zg1d7o{@mis|Ku)3&<3;T6__KlaPf189*+Gy6e%z2l6jULnA>zl^~tsWF{+=D!kA{* zfFZW|zvt$)#T2l}3h-|4y{rFW%}m2IEn{z`l>cIZGLcWKxv%-Lk5CR#l%u8iDTkNy zGa8v*n`S?*(`Mt;MD2wRV}_NevhMHEieqLO-KKu~H%H{D-I(egN?-8uKDlp~e4p@} zy^lucleIBRU`+Kyn;!VhfuC+P1U-k08H^71uym@aFlOcGqRx(aE55DI_KpV_iO4@z zGIH(H(h$=PvmJLXu}H|#`$i0LoH zC$rL$43CuSf*GnZs(lW)x$%ahpT$?cw$dd?`+A7gWP9KQ)6-Kr2H5$+j#o z0|vGT4am3OS{jgL${2W2$Y1-VWS2X*IDQU*LP`QOKdeuDygbi(yl|b^5)RkLo(G@q zc4C~lI4F98mvRnu)T|^zP>bY!v`%;e01vwLy2;gd5M#vG^|NyfsM@g5e7xV6I2(|7 zG(-nF?U9rF5Cp)#{wy%w;i!z{EVq$bc}4tCqtLo2FYlBZAq26fMSAnYsc#x^_+AAm z$eHTRfo26bLAelDju?WR;7zdMGi9s|LAz`6O#F zwBfjOdE{veRI}leg4SBO4*exU$+0MLm(7%36{V)8)_(yyO3Koq2$>{^B5(%JhCJ2$ zm7=;z=oCyrN{uK22GU}xFF!$?%*@Qx_3TXekr?ld2V*42?6eITW-NXWR;X>ppJ>o48FhrzjFC5}iX%;;~3M{VAGaIMy5 zLC=>Qv4Abyc#^H!7e%w-`K{~E(Td0EcBEsCHmxh@$rZUs4f-2Lg4+d+a8mlsW^wl4 zo1I_1ybQ{i#@{sQ>Do4l3fA!~&?eY^H0%S>tA&Dh9?AM~VU3aMTy0RUc5g0!C`}-0 z0jmKRPUX{DNt(zNl|wN-dYa7>rgil?dgDa|B!(skV68z3KiVD9Uk+D@2pIW*2~=`kSxP77 zcDYR?lx&%}L4N75PN!*t`yyX*`1;2ZKPbR0f-vC!%h6RVck2b%`UB0%!KcHtJu7qb z5wQ1isetq+aaiT$r6TIF2Bn_+_B+kb??_-ujynkki@-IE*s3<)_;_bQYQ^n*k}z^e zcK`ZHvLbR9cyS8unzVfSd7SB8F<1RIXO zFe-xl{56|>bD`qWNIZmY^ABLG*$%8E@)d`pdfjCNO3srUq_x&+ZR<$M`aZV%gXQ1KZ=AhmvTE|La1d$%x=9qlq_3!gP_!jKE*g{?~PE%bl0d$Qxmd6Tw^pI<=5>p73xy=ax=ciBsQc!VaGOKaFGQcTg3lyrA0I@`D}hGrCh`sR*D65*hL$A*nNUr)mK8PRo;VzcnlW1+`2mJm zO&0dBc#*!pe`!@7E1?@Ox|qM4Ml;G_Po5X>JX(~276qIT-IeuzHq)M*!2Ngc-j#se zTVa^jdsN%47paG)=o;=%KREIdP_(4WWPO`22kw8;E`B3lscI?d`z&Pn4I~;$ac$?P z#9tuXLEH)LzMrAObSuOWz;rM&aGUp<7l<1F%%REG#}kG?AFIL>hzvdqX+b|WFq`EM z4GoQKV?T>vJX!k%gn`GEGB8bLz}q5lii8e8f~OqkYgzKdZ%jwq-x(33Z)k^pWoijQ zNfwkj7hn7u(vm`d&;kxLG@uPK1GV_zDE+qYU4@*x5J@m@5Q4a3u!ol*cwuqCrAyJ= z+PX%Jf>7y(eO5cE4MZ3iyxz-D0o}vCLQXr_I^3-u%+oOQNDFw%3)tmL8+8qhcH0t3LaT&_IXR5e9B1gV5OTuV~25ddzoc3_OACKER4!LL~~!R@~0G9J+5+H zuQl+q*r3ekY7Zw*KMj|mBuNT2OPKS7mix8xoa%;wBmxAuyd6`vvRUXC-~^7KKmo-F ze@ijjf_bo<@*X|Jovvk7{rx-7_!9n`{mSsG6OQ{tCMkdnJxspHf6+*JQ$eF%_tDoU zR_Y~>z?7aAC(Hh@wF!sGXR6a5l3nV9#o%wsyNRA0^~e&OO$`Yq3H=x-sVhv-^t<%7 zAw76Um-3}by|+z&KqpDg&UGmA>N^Aom)~5zAs*0sC~~0wj8c>{Tqg1w{LeiVgMakP{bObq95`LlF`Vup zfgfv)Gi%#B7cQML_`!y-T-I~ojrhJaK z@1?UHGf@!%+vA(iBoc$-L|+L9D%jnPjSWo+q1RS6^D?Zm!nBfcXgfeUv1EW(cykrv z9tR9Qm`zi#fF{+)nYPj!?k9%`c7SQb39vRin_Sa!2Vvb46d;71CBw18#n{3QpqdB1 zYT$9a2VnantMk_YJ!Dbr#R>rwRR+|U?MNp}cKTHG6DU3N=&!V3H znvrq0ImRMEvzDtkH}`|{##rp2z@lh@08LO-38QkBg|kHgIV34aSY(on@$Pu}Fl(kH z4%4_dr-*5axKRsA4SLoQN*#ej0ufr;wOkacY)Gon{`JQ=&4}g?JIO;;V4>`S{9z>M z<+`%1w7|!s>nGiyl$Q7dT(M0~cyHM=!$1p>PjB`s_8(ji5Ei_P4?a&~pC41C_g`ch zD&8wl#yQgQ&2~JTR>gKU-0zPlK6w`XGy<4jT~>O4(wq_`d^?`-QaJmTzL$+k>fF@# zqIQd{0AYfwgYmV?+MVIccS?`nLS#l8nu5V+ z8C3p`{)zsUXz!}aS-!)AqrXYHL@#3`1rd^`dqFXBYsCDH%lC~xz{IUMl=IoQ&WvaF zC6X%^;%NHrU@tlpUw@CNLfWtF5r(nv927TtaO#e{3M%C^W9}#8F7tr2_|8?jXOC@J0mWgo=-W?r&2$qb_ho1si>ATxlcq$p z>jY@N0DoY>Sc55u3mmIQ$e)0GgFa7LaYy##I*%&LB&mXJ9Ey>0FA0QxFQ$YEW8w-7 z0&1xmHZbBhrTwl8EyMx8{|+#n$QwGrfy^OvBgtxkBFzcd$5kYwRs55z7O8=e9(iAf zG7dl`a{%7T0~Ao?p&4ePnR@+dCT8mW03ckE3klsh5GKC{&Z-7P=Y6vkag8a@t|A0M z+ziI(M1?oD!og8qQX9}$2HPO&Ot&i8?}SQZ#8i=y$ULIuLmwj{ETFtyef`67n<85v zq2+vhPQX|kw+X_UN4E`4H*pNA&6fZhuewT#EKNbH*#FLd$@cDsIEjN+@tY20!G4Hx zTU*%>4e+=vs>zo{H9l9k-KZO{HK_~8ly=l+{xNWj=aCeh5$`sG02JR%NEt=zX;z(Las=U#6b8v2?p zn*x@3B=(Hirx&Ah9nO;!z=3l6EvsL^OVg!45|UaVwKektF{r$1~+oHIH~)6pmZZuF*=KI;VfM%PIBwkj}`?t zfqGS(IrF7n;GNO(36q4LY)4qs%wQ8;6g{}JXnjo}cX&z;^?$W~Z^A5aNj8h2MT#b6 zOXR^QW&uU;mQf zih6@h;HUA$dht36z;v%q{{{1^&qF6L;E@sv_JPl4cX$TL6}Nh4?1eiq!|>K6X^c2^ zLnhM65cs&svhAY`sp`QBe$L;Y;24M-YN}^jB$W53={NJx;z>Q||NZ;B7!?s%Ed!5F zT8wBW7|J3+`Sj*qg+Q8ldhn)~J#NElFB-0qTJPuLGU5Vo2~X`^a>YcV$JJh_WC6SV zOwdp>M5115YbWaT-@1C2CqE^71(2-_(oB~U1qfIT>=or7_G+CUP*~(x_Hxi=YR}ofzS!5VH;E@>YTIsgO?>0Yzs8Do zJaA8@-nf3kp3FG{%VYKJVyRl$J42Ad_#xmKG^D%8!pY#z*5_Z~k3IV|c~IMxo!$Vz zOD>0uPmwA_CS)HXkA~Np#?4rI!BQAOT2dh5Mv=k}Bof<>`^UE#4rvo0%zR#urUJj3lHB-@Y^+*xezMI`hH;7vxTvdBi=>E?Jwj63+&rjnOH8gQ`Q zWDK~iWHgJIb@>k<&k`uI6ixwU3a@m?1;^E&m2>zl4#pF7Wg=jhBThj$;WVu4ayvVH zDhZrL2F8pB$USG#llvKP2$(x*0RQv_iNn8+UQD;5b{!az>IkKhk?x8LoAHQoA|zW` z5SlKKmv{GN<^WESB?JPmy45oz`V*Y}Yur|3M&ChIM<+TTJ1bfJt#Ghyk9&2G?BjnI z;4kgt+F3dLD2aH2CrbQDb!!yl(h#`}!smz(3qRac=(H0A(eO>$#Wz|;5>K!)(HA`~ zd^~oLJtgIPuKPJ#cf$40)1wG2oy%ul(DH36l~dRiXd}C(oi>y~=2g^h4p}>F=TrOm zq$ii{uIi~Xg%M>W@@Cu@awya5MPdR*zOMHnkKNg{DlfG@Jur)C4TDYMp1nyduL#%n zt`u3ZQO30s?x@%6m%F|d8_GP(E!5Md!JQJ|HW>Ky6M-<(XH)UI!W#^3dz|7m<#!=z zX%3YqoZhB?Q8%~XDZe%LEMy_JX#EWvMu>WC70oqkyOW>gA7H&6lj*`)pjB_Sqy6)& zi+17N)mah&mPYZ&c~|@QD1O8t~JxCQI5B`WXdg6dKTc|MaCGNe_soqO2`gf%A#~Sdo;6C?r?lnzpIZ>F&bRk~iD@_N@8yv>su+)YC zbPGQr;p(@K3^J0z!V7d+a~n^4A*j1)bt*ZTcdaHTBLlNtf12An{UD7~E^3IJ5~C%u zlbsnqp&q!azn#x;^gPK)bcR{QJT?UVx)b3tye?tWwSqH5@_9|;Jc zzxVc6;~Z^4OVJiKJEASKzw5=T_$o%X?gl%dGWgNDpp#~>PlV$@*U@|T>ny+VAij1h zR((NrG>u%}*xG6=?Sy_7^Uq~t8J@b+!0L?3wcF1dN)(j%#v^>B82`v9|G9dHD#4^c z23!hR78$j*wG(UZEnBk4$F^e@1*Gn}&$4(+l;*A7ufr_*`RCZ!d0BXKQaOdiG!_@FbJ@o`y!o$~}%dic8*G|EfVFofL+)zyu%-G`qrGyJ%5i?h} z1raKc3-N&q|3-t@p)rel13^FyJVjJyCj>Ml6VSAkQ2n#nelRK^0cF@xl*{R%sboiS zek_~^4DBPY`87~NlHTtHq$FU(r}(?M!KND}XP4FwqgFzK zkK{6MZo}Dqt3exKYb%YEV$O(84j8!A7XlX56#1zq79J7AkwtysB{cOGr4HAk-{TB0 z22U8Pa|@^ndsp#*KvPFZBk-F zC{_HHl$VcBYU{q#pVTIsRo<7XJFNl#N_vwvLn zliBfCSJU@A|1O_?+hnBU%S>?Ki&9ctTK(QzGAPE=`>B3*W5fGmdb1iwCoIeh>mK_C zUdA?vN!sai@zGxI8d%b;`X*w>C$pa{7UFeaPmi(K1-gxjHFUwqxPzJ~eNm7D|{@&5js6Y9O}7Hssp5c=_8BNET5hq|fC zDya8f4tT4g9)pCh(6RAxy6gnYg6@or3~6@DTSh$bm3;jIt{~4Ve%tkLwW-VF z_+CKZB>p|R8HZp9?Y#^j0|XuonK5Gu$hESeMAWy(R<*tS-Nq?axc-g&F$t%@bxWLz z=;8w^DM0vyA6LDiNI z{OrmAKx+Tv5 zItH3U$I9M#;GqXd%82!xke5O-HPUAMSjP_DnP;VSC1nG=qiiL9=^wy!UyCsLhi~Ua z2^?<8d{uWL6f(ZMJSx&H?ycRx%2mYSZxasxgGgT{GU9;jA$@bdoqEaNKh_eKo5T|# zX22NW^!KT(_;JzHPui1#J7z1tDCagbN$LlY&&c1CnIBYVM=5oLKWQS}|Hoiajkv8o z_}DGX*Iv^6XJjI!A>K$I_aRM!iZUmba&~?%^cl@;v3XMefWfNj_sA`Vi-mbh9iAKt@EEc&v>4$I zZLTy(T>hkESda8__2jc@rdv&{czymuKk;&qGX415aYsom;nt7s2a`j%2K)MlOtsrv+HkczbX2vNU0*g}3WqoWCGSfP^*^G?QD5}4@fcp9A z@Dlk-HV#isnmj&(rSnu^^g|8basQZkv!OhNERqCb_b_t^IH9Ytm4S$`&*uD9y>pU} z?--dz|JpW>$tmMf29TQ=R-pWtjGVOM|FroIy4e8D)IgG;C4$+E4y%kEJ3JZSG_~bZy|<(QDP-9>iYDS zHQ*N^@@2=w+GbMuu9G-Ww$YDJ0|8ZlKN(9wnLJX%0L7>b`d=q_!t7TtWVf62xjat{ z{!>II3yW+gr4HIHGQcEh%>CfxS<^Xr3faZtE3_>0RqFxCEQMBZ{nlT}_SA{LL2IT~zKA?`2O)=c#kG-U(|qFEwDdk=vBhB4B(QMSLwZpNGU=vwnP2iwBqG<()TaxqBg+ zVKga|ANR@|iTW^w>O=6)i|Et zKH1zfgi`~<2Vxi8yLl2t+krMV=9^E8Qv%3X{28`unjd|@5`Oei#Pt2rUP9rp|D6(0 zlv``@Ma>IbX+N-F?sb5K&m18lQNPMJnqCet1yRC9m-&(T*}8g4u0ll3gOm=YL*TvT zn~}K5sav)81!Ui_z}#ZfYGToe(3*A(_q7o+{Ojc))k2qEqVCrtg&gHpyqo>xO&d~o^1_%CMG7v;B+hPEeCQSqij+cISx4kq9_;g zkd`6od*1!Jdg3p%F3{!Zlp$wMy7SQ zWbqw9#6@C88NJg-*+`|UXD1be#Is*XX(1dDKSo})reUc4K-YIRC3q+y}j1)EX> z7}Y#r^vfAN7+LIjyyF7>3si%03BYn6V{g;D4q<|TYy z0!@)s1b^YDU7w(2$Dhz|df8h)X=i`TFx#SdTuo*^{UDNviP&N6yHpTmIbGA}Tx~)# zev)`Des(x#Tb>wcueOaWK}s=qV|W{N@5%rA+G=0Db;{YjWf~7h(H0BPWq_8Uxk#wI z$~z1&BS3*cT`rxscik5dGexgX|I0q?V7oyNpi34#LS@^AnF3ILJa?ICHn%uF z(kb?z-SWMlR7;ZQ*%i$kNYq-xf~hPW*fRH}+z*)n2rUdq{thAE?sm?Z7l5L-O@ffh zjycg68OCU-;0WXSM(_Tt1Q~!;KYsKLmWdSXw}+T_8nk~vp<-2~occAYfK!D7;J{g?(DRH$E1JJi~aZ0{hV3`%%Bjfcb+;?n#FUK=#$p{ED^0>WgvyZs2rU zp=Bizj0$qMjio>UmYAZe*!$wR*XN=?QsN*!!009;pB&q(+_GI-jjm$xOGUJ>!)z#M zZzdtr^H-OMe2`%hoWU4Q< z(JVZ*Au=G_>)-qWb;0$Vd)nLUS*o$N_MCa>IP3nBt!tmz(o15_mXK%P!`loPIt_pH zo!@v`2PI(qGkw?VPMx9t6xKB=XKQOprSr5gJ{$DlQnof?o9At@C)$`5N_{{!-WuyV25i z?OK3*>-wv=uVm(vmVME-;6mg3Y|;VOjbgK9?)rS{S?4(}p#H{ps7jVC*0;G*5wl*u z+aq?9!E>vzha&c~I>?EGn1#Oes08VIY~&!oF&3pv-6Ttkvz4p5YGHceB`fQH{eI|I z)UR$cS4P-^HJP3`JvRs+x=Yhkvr#^-0O7d)Ts$Cn6f!N4IiLpa>*6DxV2c7u-d*I+ zjS%yqp%Xi>HrfHuhM7j84%;If&98-pn=<3&-1aoo!zJH#!IzZ)7wBS;<17*RjH3Bv zZ{^|{qj(D=-2E}zKB8bBJAr$S+B3JYIadRDj(AA^!=uAZIb=VC1G429H=);q@%P>} zUL@99)308CSYTVbKXl#q61pV?deE0}5XXpPnu6P~OP~m-C=jj=NGzQPp@VosjwGNA zv3%yzbq@ky=9+LIDoezTljIJfmrlz2Yz|Vmb@C&StjSN_mKw*&ivncgsc7o^F%`wV;@3BA~>mrmyYtp#Oxct{<$J_Si-5tL|X+Z?x!oQ zBRZpeq0Bh)>b_*~)^Z~b%Z&%}b{^|HIjsD_rEZ=>r{`Jf$jefR6&r80nN{@dqk&RF z47H|j2GJQ8KSFO})XzhI#EC(>rjqVYq;iqE*9?@hLVw-$znMoVgU`*Dxnqf^ z9HmCfeTOAu2ZVZ*K*``9AR&mPt9UGawC{$IDG9Lsy^ZrLl#B)FrOh?fe}ocXPgP$; z+H-?Rhsol@iE@^VVe~OC5Kfi=%@3pQtSd>S#hY>Gvf;q5&*bIh(@iu#h#>UGf#bBD z(IVP*?A8l0gp{%7kdQ|H>+RyBfYp90yZAhXy;S0-j+LI`0NH)X zviP;*O&BT03q6`Je)ZrFKA2TNCtXeAs9uM9+Z0|T>N+yf! z+^38g(SV{kgm|`fwRda$gCx*15ulUd=zK27}994)dG4<7o9zq+Xzy3F9gC) zkZ8_K`4rY<1+pkX1WjopP+cMLw6wGcq4SSXYVasl9;K3&6&E%5r~gsrR{v`8z_F`( z*+#0uH#cI}qrvwRtn5qiuf7l&^PyDw^3BM3&>`#%_aL=-)9(JB6hZII$)U~GKb(6H zowAsEPgl!4wgy9WZ*-9^FGfEx+D43@92ooD(a6zn#zW)%lS@Y_TyG0pee9`ym>jux z{tmc~&&~_sFNJZpUfNi#If`3oS=_oqv-}mOG=s>al8~8SVTEjf=hXIIz|&N!vx@!? zX~gHlw0|8=%rUv_zocKSloW7A{B|5at?A;T(hl>9e-(1WIP}GTG1mLA%)Ds{CC zQ{G?hewOqU_Z8;SsBa}+eG)I%GvvmDPr5>LLU(!gm;H#?&gYMfjYCZx%W;*3$@oKw z4aNDQNhKTrrKOhVrZS~WxelwyxF_6}k^_-%-dHx)Jf#CG?|nfrs`o)rpwbf+6B{RP zHg~Bq2tcCa~A!NDVcFeM1oT%CLTY9=-?C?90 z7dkrmhnv$noobJB>%fN>(T9p1*mBj*3NH-`HhF(QZ&HwFyJ z9C)C9Y#bd*Vn8MUu0f1HvkfTz4e|`5rVE|19uRTpbO`V?X1@x%TY|>VykbB)nS$}9 zj=p|I9ETHewg(e_~YEK6C5W z^~uvPs-~BeluYnF-spR(9KrxHgQ7 zTnF8tUx>+tWV^EgzjrS8LFs=4eAM3?tAXC_dL!QW1Y$oay(f8k&v88k@1mN6{dL|? ze3N^qkhZWEMFtO~UcU_fS#hb;e?y?P!o>R>F1rGQHsa2?_rt|VcA2^3x$QM8v)TQ` zrRniF<;Lu+SmQUiI7wr(g5GD7-bsh&F7|z-lg;;i#Av5q2A&_0?z4Jht4UtWLEE7~ zVEtZ_*y`Ps0CT4IwM*gg{%&I^WFMqp{V}9(+QkzUyIgk*27T^JRnSYzeu*g#`Q@Y6$lFijEPpdU_pk$Bu4AJ{rxh3el6|ZhJO2n zoB{(G1&FPrM^%h!mvye|A04vR*2 zf%SoM&_tz%{OazZ1hJ>dXcBuINM_7<8befO(Az?S=>?#scvgH5yx7iZ#v4L_j9pO6 zUcud2KqXO<%wKT@d{E0A!ROYjAU>?aFfo4+tY*dm*(FMKRSq&`uvmuVpWl9N$_&u} zM5)^yB7r0@E~+mDhfgcD*X1V6i?o4;UZON7=A`{86Fe0yGk*`x7xGK#yKNiq?rYOR zr?Cjs7>i>3G;3NPLFPa-gf^Mmvfy&^u~aIA$Km@SONO&)&&uzoQBs6AZ;H)qVZXL7 zdsby-#w6f1Y*!G#Hl98b`Nv9Y%Bo){WDE*IOP=9Dx*94LscqQ1%pO#{Pq&PB$iP{| ziGRP@foOw8{-&8kN{EkXSn^Z`$VTMs~CjWeVy9g@%1b6 zF;bl*m3JDWR?*=lCcQjo(&7DrL*9AD^XkOM@)<&JvM&Pg8z@BGu3%HI%@fDq8xS6Y zIZtTwCw3b5%_-Q{FW94+B*E4j}AQpJZDzT7&Cs>a3L#(M@QX6A?=}WL%uzTddi=f z#-vevo`j?|9Y3&*Q_91Fi`qyfR6ZJ5RR?VL`j+-<>Elv5+J!6^fb(>!`ur2nCgxLp z3{jOGESYwS>37yzQ?jwK=~8?CiRLZ6lrM1PRl>2)2+g8y=m$u;$X(~tLu5=;gIJU( z(5W#Pnzla*Lk<{e4tY@-^bb1S&qxx->)?mG6H5PmM1EtMki`o`za;fwlqo-70_nRb zJzlqSR~+}$2+&m!HB4a6gb{XhtHyl0NK-w&z15&1bN&-$Fq6CmDMRi_VQL(|MRs5i zfG438XH@~}oGcSf5s{!)On-=ZX{htK9YEGdc1s*z5Z+QNjbsqE_!2|ya$cp3}dM8LOGDutrmQkHI*^sEEBznrjOy_^5Iu}}H<6fzaQf#`@$iU~@8*zqV0{@YZ|)ssHz#KifY1ay!Z2 zQoS!B2c;fjb*^r`@b3FIOMOTBUQyZqQ@mdgjR=$aY`UDaV>f!G1t!_}Z!6OwN=1Tf zbixXB{O=U{a>@$IpeS4;v!_E(A^?l*&~nD}S5uXD-aM|7M*6+wh&p9oH#8W-!2=Oj1F>UX&|XDH zzN-agd~}dg{kw*U%eGyTK&$Sx++A1NKeAoL#*Cq4aG*V)^9QicdWu&VWV_&e40Luw zp{4G@#~)XJ`eEEo;{NjuBQ991WsXxy<{;_#$I_F%2oKHjuo>4|a(mPyV0v*KLDODu zrmwZO1hj+OZ`m#1vi~=t$Fqq7n%Y|XpSvC}9zAMwEn1Vu0|J^3WIbR25>Q}Ss~vM1 ztEx%vw0U?M8lxJBF1$Ysi%J|$S64WnG`YeBLc%Z#$-{RB$Z&Dg8}VU8x3y$ z%EH1PdDi|xd!B=K3=PP)i;Yfrt7L3Erwx!qDEe3ma)wJa_E@K2{|O)&p48;n9$EhL zv52RAH3)nxDql@Vcrc&R-?~*Cef`$K_s$GfE(0BDovjDvXDiR{VVpEv%-i0SLo!~F zz5l?`c~JE}qrwkAqrz84(RV#yy$kcEd^6X>+XsVu>i)^;l?FG$QL;DsN&br3Ax2Tf zWxv3qry$Rxvb_ADE6W0;*Dg&Pa$WF%+%_0DF>N5(@higw)1n%)Rt$?9QMaY+Wi`O$ z!24=ai3mYTd>se->~I_45-wO}RS^j zl;>4UoiAh?j7qtlaF?`Z)uegwJ_2Pwk(AEXjvY#U&zXq%M^C(&0I(s(SW2s!vMSDp zufkN_QkF<)N)-`nOdi>y_wv0-ryTxF$(@*4pJeUdjg2uyoKj}|ZKU`MjCpmvhHVKF zbmm99f|%n;%bLq=p{?dYkBe&e()T3O+trf-rsU#w;Ugd@ct^ppDc{oE+%*T#ARr?| z7s9F6D~pWnc(cJug7@zQPo%B-vCuhq@d-^zytSQl7DBBLo@HgrVeYbkVIgF4e(ril+ zryF7n_hp30xMq86zPtS5a!!6Z=;K36{nt4Bv^noEt+IbVtNzJ;dPgjkwzoNPi?%q9 z%2r@q#D9COs@)pgjHr3Wc5C9A$|4uLPmZhKQig@H$A9IZyMBbU8<65Q`B}R@4A=rS zen9iRk~s5%RUEApBu-`fEw?VMe3Rjsu?J1=<<=eLvmUF8vv#{#KaInCIn^gZWK_mK zZV!KZvUT5>(a7?F>F>3=rEPd>fX^02HX;^}*z=@iAj=49PCbxW{wsR=+hp>V!8HE(U z7a5sy^|;W(CCHf-M`VzNG(lu99^3sro^VJaz$C?GAtL55W~ReJ4mE=8m^%ggJkrip zT>*wvYUt5-jZSrkkN@+Zer@?K`RP`raoy{k>UxugTV zW*)tLiS%X=S(R^SsxcgibovVnP?SZ7=D@534BK@M6m1Xb`;|YG&^jny3=GG^w1sC2G&NS&{`k3q< z3wq^6B#SwGwR=@;)|viVJ+l=5xd18jvLs*h#0q$>hU~k}cAp1}%RTy%q~*YT5n(mh zToL9S3^n)blu4z+ol#!uXKOD?5usXmEze0AdzQ*K$^8a9>sZcSo^LA%qf(j z;U?o*-<9+mlT0tu1u}XslPS*~-RsE7M!%FDlpdv`vt>7rKM-*OCh3~*dAsMdVZ0kY ztQ(vrk<1uTm~UJblh1(|WmHrMGNy`ZhARHJ`}RZi(l_@Rxq*Nq8@jw7$aLQNZdf;N zK+D6Da4u6`zV1PEhdd;rw|^^IqNS8AGi6&WN|7qa!Mo0Jd82fwWTpV7{z%}+aIv3J z$z2U7HwlpQRp@k8OBz^hRCrR&cMdBi-yc_{`U%^+nm8*Z2-x7EspZFXe<&|6XTxv| zC(vI7(SS+cG@mut2NGF)$AHi;EK@!`4@U{IW}YmV{Q0?neDg+(Pp6Q-^o1MU&Nr4V|oeE=@OjK*F48S};s0HZ z{`^4CP_bJ`IJ**AenvkLm0+9_(qJ!$gtb?D0?c!ZIevZDC)BvpdXr`|(O|G*|y$3-HL!0LMm- zt<3B}ef_e?5nih6uOB;UK&ZUz!;Qkbtj%s0p}{>6O)elkkxV12yvH3bgP$7n`Li=$ zNn&d^1Y$~$Z{T`IwQ~d{BtMIHld0P!8*RqqL<_EN>& z_f%r;B5IDuBfTMm1|WzT5&4z|WRU;^qWHiV@sRepSS>3y#omuYV@i!Hk2+)?zAUk+ z7P=#5Mu&()l*AG2hX)SCM4?#_CHxMnvLIu2enBf5b@3_Ov*wRhFH6QJ&!xUD36chf z?V~7#oJ0BzD7bFfbp+qOqg@ICH?enh`ZHNs*;>`rVftn~woKEbgpUvi&>Ot6D5%wQ z41Y`8-t{hp3?3Py>fbbDUzZ?JU2fi&!3P)Ze#QYiAIEw|w3Kfwp_Y||crl&|VAWc9 z%ow#ySP}2?X7L@E^m_Lw(aX$#81~S@oHc?qqPDW=#sDcVx`LMQ=U#l+V2^8yNCX15$ zc6%r}3Gk%6Cib)Po|v#UHHqXrK9~ZK&+(U<#y@8NXamO`+Ge=cajU#7Yq3Y%WInQ_ z&9#W!nAM!hTt-B2*L?la1KQ&wVExjdSJ*;)@P}S%&U@|C98Z2r7xT=yMs>mV;fBrY zgR^nrmAXM;ES}YibBwmDjYe;HJc%=}(DXgj^&n$PW%VK>!Gbn}vEX~7X{FldnURkw z)O?r${xkiD|Bgwz8(j282j_QI@ez?7a>{6GLF*9*l7f&}gw;~&v=OE~f5Ei~mc2aIgwRVLC<&gJlT0|oH=Uuq&iawYkz;^+!kb?S4 zv2zWCe8}*jYwppAm^1^aaS*J}?UXhY*sIzmEf3J;)8ZvcJ+rIQ6pg~p1zi#78E zx#C;x|5+B&2F7Fe;Gb}%BtY&V1V&f3Rl$NuN=~K`H(ES~eArD6-ipm0wlh6&*|rHn zEf07u{RWH0*pOx7I76%^h?auc-5D!C*MnHpgAf+xW*+YuzF^6Aha@~GzXwsA>9Hp0 z@MIx195VOepMtVw_9Tr#AbB#D>5qW@bc2YNm5Nd}-w3L?3=jxj3Tzrm+u%7hGh3>; z?{ywTV@m~gj#n3xkbC2=^3-q7pWJ7JoV7jsIOHzFLzRSe;j2`A@xmcjwXS;^2ntFuL@jRgs?8Y$`yk;z#4U-{FxQ8w+g-y1WhQln}9j(w8| z|E{pDG|xgsV%`XIVFNCG@Ps(NRzfHtqxjpj@5uJ=w{G?W>_cH3f?81p6{BfeLP z_yy~Y`yWaih%Qe4q}Gu7K~GvV85&UF5w>En^UMz>!y;$IqTJjS??3!pUT=7teq7&; zENLHW5bR#Hox-E28{Hp!jt&+F>#x6cmGJwfDs6+l zlnDYAD#n^AnyuZzRGP5vv=m%T1(3-dD#`7vMB9WpGs$zmA+5shN$>#U8dQ%9!*ltG z<~ljP#*kyq9y!^3UKr{E`dkU0r?=f2F?tE^BMLe1+44z)o99BzpMyHO$rRlo6n|_( zn%>D1c5`mKVr>z5{2b)+-bGZiAM8GJaB#?pV^+o_U4PEg8U$F(mvHKMthndr-Ll^i zb1&WTt%Q_H3{q|a+9w*AfYj}>BFk+&EIXefPtSTDkSnlRbdDTW)XCk4gR>+t`m`++ z1m!qDzCLmd0{?Ipj)<;WUSy4T`+KxK=V5K{&?|9x9hgg1ilnNp4uW4+$=2v319TZn zr9&wR53LGJQUsnkZ~i^jh7@^#n3?x~4Ibe;yi~CAGOdfYpW%bQJ*RKL09CS2kDSbt zKYe}M8%OfPijw|=PT^-B6#^8nTzk^t|(yaFoExB68>+GBTz1i`uuttewY&4e`}aO=_V4G{6bM8`es*@~NRG#E&bSl~-#TCr;RfRe89t4tCTl%033Qh>6Rh ziP}2Li?LBzwTQ|}9^2Ws)=kTW%*sr|UuE#^Hk~B>DfdAZqYBmQ>uZXdH{F>|#c0K= zD{qAQRNqC^@3K#xF0Xy_Fr*O_$h3`6mN*mJTO-0T<~cAVDQJ^l6$neW`IdH#r_Xrq zOBgSptW~mU>n+>v#o(QLsTe|jUY0FYS>kTmSWNh|%&K`z2@X5TIajjnet-+1+)o$2 zk!3XS>}o|im>nfQ%bEpb?X=^^ceq|DLt|<%2ttJzI16uyrPlTEgv}iHq<;h$$y!yx zW)CDN^aJIIg=BWjJFunW3Mu&uz)hkdRc)PbCMo+iS-<7Db0`+=`W1je-#FdU$Mk@D z-Y{6y)iVk)f6IsFs95W51{n$Pg8kdS`uEL&RYl0q*gg{tf(1Y<(c-9R?VEQvcuI$` zy)N|LY6_W?NnIJ6y>dC#RkZ#?Rrk?~<))xc0$=yr5cZwqCyc5J#9O!8F@-cDW}zmU zj;N=Ty{9RvKcSevAaJNq06b%lArW0J^j=VyL=VO`p`94$)QeN5u&msd+$BU&Ps%1h9t^J_Ku8X zW@gI{p<~bA?frRtfBvdRJvd&k`*}a_`?|0Dy6!U|zaKd!e$p*3hz>X0f?wA#1=ddf zMTIXaqT;?E^vpemWXxaa^SJL#=wE`DdM79A8w#~j2b5mVnagn5g zcww~3^fNu)Y{y_UMRSncKdUOeJo~{b!cS%`@aa^^^9<1usDqtn;PYd6ku$>*E}gqfsnV6;U`_$HorJ??_ZyO zeE40iT-n5sv{L08ZqPcsgtVz!Y5Pl1JtQ0DlU&`QkPISQ?>D}CJ^(#;vYDJ$ZXxxT zk@YidMLr|jv4?3f8w~B$w1KN5_Zx$6XI)0A*E(G3njV<{9?u6UiN_i=$3;issgB>-Je)vWc)Hg_;5+(tM5JXebH>644n9!OT{toAKTvJ@=D(~XHWO_tvPUc0# z+QLuryW9fI(#^V#1?1fy?gm-5&Qh6;0$MPY0S0>&}{wwWmUXVsCMm%0W_lW^0#Y{GD@TPdo9KzABA2de;x!(fU7XeL@b}$&R-YINyyv%Wd^tFenu1_T)EDC zs;%g+o+M5Fd|-cd7kbu~C12m%@`kH{Nq4RMqkGYR&%V9UTm*S~lHZcLHuV(p%5$N& zH6A{kU-2f9;0@)5?Qrv5!4=)?a<$1z?Bqbv;|}!yoXqyJUsLu?Ij1~V4y|6 z-jEJH>-a&hF;Z@R>q<1(llzaFel8Zrg^F;kl=$C%9IIl+@n1aXUBZVC=UMR?zG^{% zQSTEb%4}6QP0%;X!Yd_pMuZ3)_Rbl3AcuuTCo*I=IMi{CdKISH*I31v&|>SqSr<;* zTUzedPfO$mVFG%)9BPP&fd%3%e(H~M9}j5!aI;FgLkW6p1iz) zCy>3}7hNK1D(wG>Z_AD0JT{4(K19ZO$L{zPuk|RfpVqj$TgUggx?KzRlScS>A$GSA zWg5ujXC#V5PPW7sN=74cFj_Y94bmR8YF7mQ8SFj*|~(D4^T zNs7Qzo7zYt0sU&m%|6#a%uT5IR8H%DfZCyM__7t>XSDDG8^7-J1bQ>&@)as7Z7=Sp zQ^4f~}(h!Sx57{;%ud~k*{mD?IgLt|4YmsOrPS>z3d*)>TKmN`j}&6(8DVz83- zw%6(a1;SD|X^FiIufIRLiD*~&AmO^)SEt^io9IT2On$~Cctv{U-DiYgVGui@o0$Zul1|9SIy|qOH@sQDgcK(YSq_n=5 zW7{JU`Vko;DWtIgAvz#Fxga$`*GkrPR6-4p|9h(h2$;w7GEFm&PM+zrpwu_SP%`Mj zzid4f>oWdJsNfEa6C_m0>3a6clVu$i)QE!Yk*UjL+to zhY@MUhV!xy=4=`B(pm^p2}~B!*Bj#$~`}y;hz%=Fg#&f@U&uo&U0(D(DT+*xYL=8EcPg*p$APe9)wPkl z|1hL&Vj8-9)wC2z#x+Z~`X{Qlsx6xly`L}Q6B48dAoTc2e7vqPV4A_(CF|>x;72}H zin2DA0!5hgJ;|m|rUX;6uf7!ziZ#tWQ$bf%Uw4^wPvm;S1=hkpMLNJ}T6 zI#|=b>1^I0cs05QE0T}f-o@Cpmg{13Pf=0PTFh|EuDBJ8P$LexEq0WmN)3`OKQITb zv9sj3&X@GUp?IPZN}IDmF+nu@MTARJy^d)9+9lU{&DUK~vE8kKZ7$wYSTZwQHl}FD z%1Z%V2=FukCQ}gOSZtgO|dC)h)plegy`icz(DH11B;UBsc9y@VfN!job zDb}5)gUgT$A}zI-0liD1@g+&zyyvLgTo7}dt#5N! z{Q0w!P~fA1welEFsZ^QY~sI-W} zd8SLaMWn$m%Mg86?)AoXGEi0J9nsC474YQvFn+J@%GZZkp)~3xcR$|2wIuoYv=u-6`&g9qTxYo>ls1BR^w%cX9 zCB_$d1Oy7WznH&Ot+`0QL%X*4yiYKMHCQ~9W5;JR{M5K}>(#9)uT#$8sD=~CQfR`f zr;VaXnGGZ9{VVYs4X1-!yhY6ltp;XqFMl&$Yq?PfXeesNI$$lxZ}^Ir+E%|jeBw*a1LfA{QK$p7KKsKdB&PI^{J@Ho)CG0 z3erlWCktb3V4ME}u^^zFzlw-*q`!|&9UmWmWX8fXi&wZq#^;0 z5zE#H^9E;%Q=$vFXNu^Ij4*`qrI!82z-qTOitm(%dbE1j`$nM2L62E)hD;%P@Q-Lr zcqf#j*P*1d>3GFbN!g#~yZ9<5I~%3m`U=5?b(C4iUwJWn=-Wu?#0r#R(wU#Wi+%J^ zINL#-f1wbH1z^iu)LA*q>`4pez}&N{?MgkK&|ck-HUWzMcfezY7caxUQxHq<&w-k{h0ZYCOZF<9 zt_IEguy8Jn*FA3P`;+*5>ePIO(nL*e=!Yv?S9(W=hU~CH9F@RrJL~MS7cJD-$RGu} z=jAFJ?vq0R%(ZB!my8IdC&Ww_m2(p&2>-MQ*5~Dx%knXhJvzd~XWG|owawLRSb1S` zCZDA&dK~xsr`?kMYG=KZkp+E7=8%BhpR+zpFHTfF_?x0Je_cIz(WS{Z23Kvq$bgt2 z%Vs3nqn*(ZkHd@)f1I; zt3(fX<(OUcoyMu)n3h^mw;%2U?}M%g<0?HBcQ@rdA$1wFzL;B2#DWu&;erjiM0qp4 zHoy=INJl9EIYZ$k7W@4oNTJDLk%;xdH zDLJUx-QBJB=ZKFvk8|`DoL802{i{W^Xm$AI@D+FeyxTsvmEU0g z`aQtbX?H;wYkNJFKo-<7d6e^Im~P)pmT*vo;qgrzl^-Mfs3S14@QN^9+=6)}77o@! zZSCl%=A`(2uRa$Gs$%b;bd&zhMSO_D^!`2FpiK7q6glb^YVwZv-}lnT+`Fy0_mg@$ zcB)pMF79~~t-4};ZldFq<_{gk*WMF9=0yg&OcDrS8y;|OyHo!|-ku-!EZkuX;jcXJ z%U=Kf{ac%^%We%Qp3_*78?mr}D8Ojw71_7arf!}P(dyQZ*;uI?`kzkBwinGux%Uj?L*}L)wW}gy_zN$#Fm`DJ z!GgBmXxG~0Eoo)V*n*e4X@kpsrJ0<0+aIM}O*1>AWv*xp^4Z~--R2*vU7k1DA1%A( z)gfGIo#8{jy65qpq1P?UsrBlu>jJOce_x!-O#W!tDsN3-CK18BvS&gXD>*;9`4dr; zCNR498HONqQhES)VrD)(a}L?SBN3R}nmDGL{#`Kgp5k`ZFt8V2Yj$eQ5(?4W%q^0s z>X1l+#Ry5jeEgqdP8v%n>Ism3@!`5c;c`ApJ?Z2CBfYpe9CbHGY;CN3boww zg!25wB`R>Bw+ixK=uZMNu04A;M}3Qw{8#hDHf1ro+TOPk$Q`T{M847C>`Tu2aR$oyN8ue$vR-zEvKKl%Lh4k@zQKH(19jE#*=qCUf! zVadqwcd{4g!U!~CB@!LQrDbU^^5XOVNhYT*vGb7%^aHeP886YSdrKnM6qGJ^;0LD)`^d75CZbh(9@;mEN$PYsihTfyl7N?QHl}4&@Pc0uKe@T zPu(+iX8h4Euu>GC{=&W5=4+J0#LGaYj>>jox()NQ&jT>03`@|Y!SO7qwX(cb4QkTn zE#q#z!?16e8<#_oz1gM49=|s)L3ma0S%(aI{y;K4JfmSDQSWrtLemhY^fKLDR=tsy zg^D0P2AkyZ$r9`Mw#xZppu#R>=-gES*@7&Ve(wtO6Tf|KXTLpvCBX24TQo{X*}&n+ zu*FH|4K3Ilz6eGKM9B0xusAn@pGkh0{vXj3ofUuH@p)VoM+(%`J}qvu=CHg<)hC_t zRVx`lh)^7&?Al^A4`G%)5vT0N*^I~!qGD{Dsh3n6$URQ#1__q-q?lX(ZZBOr-?e24 zCN*8LQ&}I~ zzP_kn5eCtZR+*0>zkEmDBBOaY_^5GbsQ5PE7@V_d9bSxBpX374-uN==bef!d8Vzpn~6gzrUgbe7de8q@+W8&$apUPP&#X z;n2){kLNf`!}N9zC8Na7iRwBwCwVG8x6l@_`4RR{`#N*<-Ns8ZL7XM31Q-c7i`Uzn zuxV9XRJ5x(vjM_}2b@T4%6_Nns{`c9H`F(dbXVhDei6lnpWa2+%7peplwLNXlzgdl zT`)bofpq4be)O+#SVLLED;a{$4REUB{j{GNP*N2m``Zy$9-30dQQ2UAY31BC&w%<@ zcFE(mVksCG+=9Ktd)32TAlb`@@zSb^wOGu8YjZAq`5{e99gK&2rQan9{Hci*B52wH zRp-yHD?h(r;}q2)vr8lRM)c^xFk#>{oeEVb0@8O89k4;KuA{Bp{li0z5FhtBAODvm zd`cT&i_3_8qq==1Z4XNQOe2?Wk%a@=iPBHN>aO-JG4HMKK9^7_D86SlV`FqQMvXXA z+uK4vM1o?ku&N7t%~bn9cDa8MG)u-b|HDh&Ph(f?83#0fg(15T5mU<(c6PTzh5zL3PacC_&Bk(eqB8Ytp;P0;|s-DO;Rl zBl)UYOQT(R0$4gk`o%tR{+E42-y$~E7}NfoD!vL;`^6&H9c0kr&He6h5tMcOct1;e z=u4*#5KdVXB9I2Z=dO70O8!~sFV;diLvP58*sLP=Rc3Vr`@ZiUM_047bo9kyk*^if>sfYSGyVTdb`@%qa|CMR`C8FPe>-aIRlGE#z%*hKbhznUcU%u(f%ujfET%jnFeH|~- z`QM;5o%R&@>B|s`>;MSHSOJ&bNsa-RTh}jpHB|=3JOwo;cuWRe(h@Xt5zUo0BSmNU zB?9p-{8(t+E6tv4>~Y*w2jTK*knixoNmE8Q(1_EAZh6FJ35nT_T^D2ZaU8d+EXNJ^ zoR~a(i$qYv#(5jPeU-^4_55m00|OZ;(CnmW^6Iknl^rfWPt6GE z1Lf&*1X}iwjiwA}d~;Cr=Ky+4f5ch1LxOPdincIEWq*Oo2Uy%)Bk#i$VO=r|yqDV% zb<9mj`hOHii~XsYd#E_i?>2Kxxr1Bpt)`9^_yVebkH+?ZWTO!(TfBy((VJ?#LdHN{ z0Nb@bUYX}!*ZUeqvtl@&-n{%S5`ih;Z}r|OlmirXq*1zH!PEbsT&!PLZb>8&!w0re zm4}#Kgc=Ju}^>m7y<&?b)OrEH6F5F z&$x2c1TUIuL!;CUac?E|rvpUy8ZOM>l4n(D{vq^V0uC!*?%)L2CLVJpVVgfcw$+8# z85}+}=f;~H&VZ^}hm9cH9jQhYihdApDWfmt$36kFqy9UimO2BQ6IO>`3!!u30YPE) z_!uu;-qeDZ$IA_QR6W}nka24sa}R@fkmAvsRC>6R1yr#n=I+=%A0ku@NK5P%%jU8F zclK^7ou9oVy4ZnjgMrBYi0AjhVhH(@!qxC+FCr`u(nxu_8h0@tj{)|$OH)#Xd$>~# z<*Xu3W1|xh({|eds>8pv!t$OSemF%;{YEy#o-g+hu>?kH|G7HK(@hMMn18RL0~TX< z8X_p;UnsDrd|=SyzwDzye5rWH;CA?Jx4`>bdNRzF;*n0DPw?t`8<@*`lD4G$s8cq3 zY?R=jSvs>dOiq@qEcB`?o_Lu$PE|Qy;);i}R*@MJ*%Ug7R7u4S|oc)`aSpY=EsnU&|Msz|ses9d&CzfpJ z4)D5s-Gr}p-w(>VJyPFsR7;of9{B52Bq0r;yDr;!&Y_WWmI7T9Nl=tdN(I8%fG=L4j7(vz;0Ns!GAwi}b3kh;OMbG4iudFk0Ae20Bfe-5_?;tZ!%!-rC6 z;S*Z&HzQ17^_rt}wq$*|Ul9>?AG?*_bsS#VRr>Um=AU@oRYDNQg1%*GQ4;o2yy=Aa zuA@e3SNXB;VSI07&RXn?%AN(2LW`6|s9OEXYihjRi(H@qORVM^NuihQ$dSDL6m@Z#~v_w1yP#l>f^%EZ?)00m~T`Qj1xj~a7O(>E-# z(+5S49N_YIclnA1#@A(pVa@>T_DPM+{~wOZ)v&?E8N8fJ&E2>E*XHXxMoxCKSo{H3 z3RMD2@upUB*IVSbfH3ta8PQ96W!jILUCnQ*(ASD^N~i(1v)y8T0-2jESHaM0=aqM_ z?h@tn&&)ePapmPy|8;RE!&_x^lqLA|j+W_TnH6O-c=24=1ntF6T#_$gZ)V=4hZV(} zzB_wf0PJIp*gLt_UpYOi_ihGrUcX9G{O^i%@zQTES^VNnVR0E@OH$4da+QK6ws!KS z2!%+ECxdeIB8=b93V%gfi00OR*3;2`OU2PgpprKt5GVRneEr4K^c|w|l$0kXA4&y=SmskA7F*yy~^?H{QV@{&OfT&cOcluc!A17-w_3mUu!y z8@J0>E^q+nicb^=f(9-Nf4CRPdG#u|2U;`FsK~<4l?%jF0a;2-FEgS;o>CTlTVUKzvQ#DOcy`F#RRK*ui@ zbh#Vgf@%a}iw<133C3}-^4g_obiPn?H4d>)B9xh|hLlFG zLmv|jcksLg6R-3=G>655C@(Ig3X!>Zs}CNnYIXm6l?kU-VoHeFxLQTL$eUtRWg zNSSZn_Xiv2n?eqR{DXGfLh2xHbFvFnVNnBY{z;2W!*i7F7w(Q!8PNCoWOHG>BETwT zkxJaVD?b!005$$S_1@!A{Qr%8Qb3K& zvZ->2B}w3oP?N6MyE&^+`{aNn21wS)UXn<&vY9uSO3-dS?(9~Fmv2y7 zQ*(Huh5x7BpxE=D)8V{Nj8$2EfMQ-&o}K-aP^fmwH4MW6@(m6!*7|tP3ihFsgW!3j zY+#+TIP%5GL18}!mMVq;@KS!iSJo#|sYP{>fG--2B8QJ@WIngo0IkVy(6+mI?rG4u zoiRKNmH)@%PStZJWwzLV(@kEqibtbl;w2gSPv$}^H{J;lkSrLBD;4r3va3nl>hM*l zYY3|TIDE3nF%|k=?bt+1Xi8}6hfETyo^Pk4^qCIncCA0Y`ZDR3*Bmk}FuEb;zha_) z8c9o&zf_{ktQ+aN@uSwqjgkYg|NFbbR)~?;R05qUXFEGnkih8F1tj@( z>{@MgqpdtC-&ls?r0<}2jju5|7AZ;ipWJBqB51fyDsxtswI9|UAu1-d7J*xiXqr1v zLowUqiC7>vbQ@Z}wm0@Wbny8VADjC>+VUAN_ zw*ck6@MT=Y8(rovbY{5nGDw3}Hjn;VX!RzUqVMLpisgPEBu2a9BGa!%-yN|k46sXC z{Gps*j=6G<%g?C}z_y}=WnnJ?e}9<%fR2{d7a?#*{Msc{2g#ue-jpKPkC?*cM1HQd zxxVrZfTw&I6fuL^e%Wx(_R7GTk#mk8G{#b=4^gv#4>O$xW_Bxz-iG4 zIszYIs8#I5svfKjZ%S6R1LfRmlMV zh;;RN(b7sg*Ug)G@I-9do&0}}j#m9zzXwjhmgX7wGeI$PY4P?*Iw85=;yv;$N_Mim z-ZZ&`DD%@B(xNP0;Ku6ueoov23?&2TLbu*Qnd@L_xhX7m1!y+LixRr4k>QrS2(b_c zIl|5CB&Uw#5>WA$hv zgM%sd5!k=Nx{FK1vY@#vZ+G9L8lx}oBD9##pS1u3nOsgn=e9032ak{}jUlo%!`{K+ zYztIV{(5UDoTnrRG`xNJJ*-e+=rnr!!<=|-$iD1g>VI=>s2K4|2a-2DRvPjaTfUmB zA=K!lJvViKlnx7GGZA$S;L#q95p~n8b~dy8k;pRu{IzO$q|PWw^MNi_lf?ju80-V~ zgKd;nve!!!S_{~eD4*;`W|}U#u`0$)Y`!V>%@vA|=X+B6?%@rQZlH6*Zvk@so(2a? zqHKEYPwd{0++P%5i=YQ{xOoO6S004l^{85k2VVbW$Mj@XI3T0*|92%qI zl9kY`^=V7|g`p;c=W8CBieMCcK9BcX={Y&4#vx=$X7mv_h>r+ipYONk+x|NWRZervKzt6(<05n< z2&9D6y#)A?5isp~+I@6ES^>|Px+X&}1KH1uh&;BY=Kimb(mTg6z6DqfI5qvTB~WEi zox4Sg6WW|*5d6+7zC-LTdGFWaGdIE>xuWwbZ0WqZdDe`OE^k;d2%>EG>&xVE_5KOH z(tpHs1cFy5K*j3!c3T5k*>#2lLLY~$j(pbpLN6T_oo(LC2~F~2v2&Z$ zi!<6PS(k`dY*NFRe%mi`l`&UDY<*`{cIqXKj(XZu0e*{Ht3jc6t4KI3eN_ zQSHRVJRNFzK3>15ZqGlT6BoOg%k;yQ#t?yHo&mm6=^%0+ZT7?g&y%|8qjLW4Gr?cF zz}rR0yMVEg(=`e(KTEoq+i5$_RFvRtgo>m6n2>@?)A|9uoeI2KqsNJCs%eM$@W+ru zGnwcjb{sm>MJGfXU8qyVoVbH{%mAEy!vS46MRZ|>T_rHY@N`%4j`lVj7$sdT2?Jjy zCMN!T!1=JVD;MNeV5uuI*^4*uIXHk~qJ61?tP2B1CuzF}$b`G3-(XAu)Mf?+ItN_O z;fi%J)q%A>UDzvDeJ*cIi@d~^P2437?cS466BQG4+)W3g#w=WCI5ceMLSE?YYk?Y= zjM(tbe2R#sl2p{ew6cTa@dw-i2I zK>`KbS%!$W1)Y2Zq1=9&`h45f`SN&rfgE8&6w6R5~@-l+! z*&<;4VpLdE)WF&@aR=u^Vc{hBx341_NUqJd+z7jo%R8vs3qw|_;B!xS^xr5&!UzA%4gOpSxlruxd{G*HFYI>YktfwpNQm;xlKYIdP zT8w5v6b5#gf1^=OlE_PhGX+Ws;eQ`0dvQJJrUF4`*p87wNc-78UHq<`uLK?+U8SGg z5U0!HJ-YBHP;v0`<*)Ip+z>(A$lSUi5`nRxj9Xp3fjX2F>RmfE@HC_7mqu{lRDn|r zXfAppo8+L+)&dg}J%CDmtgOJ)B#zc`bEg4ROCH~F1Nko8!W|;iRl;lQsRdofVbCrz z>z+qv%-gK2*%fG0Aswa9=C@JydkzRspWlSIB?sEgOFw_;(YZMDeLD>8Nvn%{ifr)UKm3gK`gg<1^@xFm*LI_ z(TYX!9yuvV$qF!njcF+s0gx^k~@D|N^3He`R6z!%PPPXA8Uu}A&@)}lmmGeQ7-LKJl zPz6o3Y+d9`Qj?-sH2v9B5$MdTn1k5&R-&*{(%EGP6-VL1!a`vXA-Vlzo!6Hqpni1z zOu$HPa_NS}q6S(xC0xyPQD8;%mDJ00aDSJ>b_o@w6p*lxgqkn%BPYp?RU~mHi?K<# zRj)83pjw%VIs1^{oqNQjM~WP``P-r3pdocj=8C`l?tD=XpZEp(D(j8J&E z8sfq40pAi;w2>a@Q}&*SKdAs(_@_$lRXK#O-ETCy7u=|?*kTJjOcxa+mg~FLcE*e^ z2XeLWPr;1G8_~-i|6K%<1tYBQTZ*a1s}3;0OQAre^i5v$1&cYHx5)=`r+#86y&_n2 zex|KGRQ}#dmM979A;Xd_hyW}4e8>+SPHF3jW_AF=iN(wb!;`s!`_QPR8?h=+d8m3!&3? zZBsGF4Fs2MX!~f3c4w~Fy^IGgL7og}_Ju31!|l_ZEn6QN*80*A6ONrk)9p_YmB%aV ztbRMQiMzvY4dh&CwYaVt5*Fto;HQYq+&w-8-HcP{g0jx+#b6Y#b4)1}QS zQWr4*f5R=Qk$%@Hh}Q7>vJ(DW47p8KL-OoLvKK#c;l!sW7jlLOAkmb%t6{Sa8h^kW zP7z&VcLXNUk($xg(z)Ip>`mCA%tz7TY)wzRaAO8!GtRWfYevt{2x)KP!_ z%aMKdUD!zDF|__nU>h@l@O@u^f0TB>NShF`ekeOVRkxuP19D{{xw*Ob(br2>&5%zXYG`cTdN96<#xL+z!k)=~ z)jiTi=8Lu2Vx^0R-6mZn({;3mK+zAC-Ii>?QBqL<*+$UEDUfBhA^iCHt!~cCaSExi z%p5N}#8O&}D{(&DT(?Hqy=8JAU1bEGdGIIXUWw#P`g zm_M2TOyddHn*u&w-iDsN6Dgds1MdUYz0p1faf79!Q)*;B6|&ZTQgXc5NDdD%teaEd z7bT#8*w!5S)YCuyxEHA0#~#n^g%zU&$boZ%_uxt&yIB4oBiu2a9$>6jchBBmC-byz z@bKXbRKW3_a+@q%arK5Vu9aZhJ8h9a;%ZxAw=_)ZtAZuX7rQVU>gkd@Pffc{h-Mb9 zGYixlWX?(V9yw!W3a=vbRr3a}Mxce$)$*bR`ein(a7Itef4|+msa;4FM+ddbmb|DE z8jT)o6#yefcOPsgsHsn%PDzdKys9K^dOCtPLBP|J>baoKIQaP?7u5&L)ssz{Vuz-) zwQ<5CVtX3K0kM~SOrzxFe71DUvOf$XYP(}uR7OznMG__N6jK*2xB5u840^Udyo~3v zsS-~ge5A;$u$Je0S4JVS9Oiu`i&kp3d8wLHgUM2!ukKy@?DX!ly0`6FI(m%=uZNkO zMdH2`YK+=1Oj?nfUN3i#Rtfb4EX)>7tt|XM5l|zy_%Eh9zVM<{Ob($9Tu;9|a{@>mSYdD^_%2>YIx|e%a>rN zNAGfaM_()(tvo!mMJo5`y#kW7mI(DHAB^7KCnY9kaG{>#&-i}u(p>p_ucOA=n zn(ebCz&r+U7-;6*@4O=y40gXzV)R$IEkXO?L+*`^yPR@>f0X|G`EwTKY#&o&=`=Bc z)i29wSJ5`GiNYLyG&-@?4imldDuR;ZJ(Lpm+CyB3TMbTXebQMs32#N;G#EMYensEK zhOB0;%`m-)B2q`clM6hp7S9A?%%cMD4+TuL9R#T&>Vpj1vker3)nVa-o*5oqsJNLO z1NyEs32Zt1@VqnzuO^1jf zcdkYK%scS?5^}9aM0+O>(8&9erO3l)ZOMe%J7`3IjQxG>RK&#eR>8lZ0%|-?ouNrn zVxNr4MeDjd=nQbC7F&8YXJAC^jxW4!&)@d!*x};Y z_Q<$Zxr?{$!l>JGWE4`I8M3i^p3<^xNsJe)pzSZ-ULA3`flE|LfIvP0+5G8CPxaDU zb$v1g7A-Fb%N?~iMaKU1kU5Zj;Gy~CN%gbNgd5WWZO0nu(538HI8y7?8}s`a!|tYC z37*oYkBcO)LGb^vei41lmTfQ3OxQNzZ;K5CcVbX~*N^ixh%lJPo zfY8-qKOgCg<3G=XWo^(r864x;I!&sG1UXAO)R4BvOCWKSRhWA75ee5N!EM;FHZYL z{}Wgw3;wI`)V`2>z8k64;4|ooX4w*KQi1)RAJ|<9PS~mjj_%D zqch@G3~a{Y5Q86zZ~)pL?(`y3nu`|V!#nPpc2R+-+K&lGy*kgW6Vy*Oc1ImJEYxx0 zHEkk>K?OaZ3JSbA)^69lekOQ8sqH7VCONj;4S$X=iF`W|+4{7+KvG*fDb(OT{bR%) z(03J^dEaq38tmOc5_56eL@ap~GS6py`tTf5u+NbRAsb>-(b(A(c(Bhleg zIeD0Yq9_`DXWjwY!#^c9-`gAlj>Z`g8J8?wm;wp$#&q)XKYj8|-h2L`5C}h0pboXS zgm~tUD2=pk$aBNTOFNGR-QQ7iDC^l1f6`3+lA41v$x*koWlX#$r}5WN&HQI%&WAhn z^l>DfFV6aY6Ez5!$ba0;JepRZL_OcVbDrG?7+E$%s)K z+RtfsRCbxt?e?ddBc-F4#b+CW&uYfVP5Z2`Rm{?Qkt_a?zSwg2iV27QzQo2OmJk0L zrwdN%oK773nwp!AYJH~62j2^x=%?tUs$Ce0t#tDnZoFIkZNU?YG&e6Hi=Jb7bi++l zA>Rw^{haWLG!^y$W_Ol^B;hzEeaon7dU>w3r~iP~jD5I6pgn0L$mKQxdw^oxhIRYb z5yeV-L@WB50L%Q1q7yPCtEC`OD3u;H7X?YG3u5#yKxs=4L20^= zat2%|naQ0+<_x>s=MI_g#y68;Wnf-bhbn7B4^7Zk7pXyV8kpWQ+yRu$1dy!J4U3#t-Bv`&Zt zq1z=hGqY71K0{wrpnT5|7Or!*-V6cLyB-H#=TU{*LKlD@{CS7wfTEhTcCiT)d(6sw z5GF|__b*Bq+x@99TK2DA3ESr1B57wLf-5yVV>+hdf4|UNn4&=QB^L%%_(_8*i0B?67I}%iN9 z%&B;aj|SO9k#|yR_`|i~u-|aTiWqM$GUIa(?^uJJ{jr2J>sUuaLx8Hum4LeZ;gcr^ z@53h|4IJX$GkERKhMmCc%_=y>@nT5XB$n3p6O2FNHrI+ z1+n*H{gYJK@3-&UD=p&cg$*yobvU{XGFD$v0CsGSb?$j>IPyJ zM_&N$DGx|3GWInq?<;X8qIsQVAqqAg4%NQO+!>Bg;s$yk(w)UX+stg?e1s(u=$rob z=qGF+$}&c_{yQ91`wDnSFJ2Y#ftSqS1!H#@rCo*?Q{{Sdcr&m|>}X6$TP(aI2j|To z<@%;y0Q~!z8l+^>@xt;i0JyoC(A-8>gR~VN{$?ZU^^sU<`LsK-T7)S!Da1g@2PO?6 z!(+LOlQH4PM%>h^ugQs~J?Ek~B0CrhI#>Cv;}?B&jc_&<=a)qE^*j+U#Jg&v^pD3|effTW2gY>#!Fz?F+do}3m#MkyKDJtR%pak52MCj98 zl=~4byU3T3k&TeJAWtcvet-O<03Y=ux!kd1(!NyI4D=<0h+~Qak%qx*C^=ZS-YOwo ziAV{eNb-0_k1FNFs4RAG72QO}k?W77!vT_DUDI7|eCU=Tu=wYh;P)hIiX8-e!y|-0 z&NV7`9y)G9N7XxbT2)PGerm+bN%zGeSdsY^!?IVNsiqfZXaC-7OFnv)`Mf6~58_ij zb;F8>q&AVeaMQ054ud|<)WCU%F?O?OpEX-&(ZFj|6T$ZaN9`b_F*` z0rR)hp)GK5YJ5R$=e8U@zQ*=^@4x#np3!Ei1!3zjbUHS6NKrr>!}=nUjA^u>zR=_I zpK_vAfeWeSa)0_st*X9KD-8zm^nb2uZNy!Ax6#Tr#pBuGPd%T?i6NZvHvP$xH4 zvrTb4lgu)jcm3*^S&jDF&yitc?%I8*t)i!<6{F)&S6H{h#hfd>KTeB!TzO; zyuL~t3r!Oije$dw*>}bR4S5?p=4YCYthc#Oj|~@{sHm#xqL*JgG#-ho(vy@<<+|_k z&088g5=keA+_iZV5Z=nB+LmVz8id|vI1@Q!P{?Jjnw_L{uC-KzmP z3N4v_ni1YbT1$<%86`mTP)BEq-ks%2#^U!M(*&D|uEX*p929ZZy;%EcfIet|{g{;U z4fq{=Vr^ZTL(wL~ET|jWe%CQ785t_dIN>vLpq#}5LrZL|A|drkLH&kTBEF{k z(5uklXUSnj7!w)LwuMmj@%LE|a0n0S~`m75ITI$(ixyMA=Khp3~0Ut8xc zDvqyhZmYHh>N3i>uxNqxOO#6Vrc4*h-jZ!fJIK_uRdrrPR{y}(-4EFPk|{&&E+Fx~ zE!mBc`0p}zLCsIyqgo(N-Ge}MF&R-nr^7sQ`f1@wOhFNOZKC;3Te2L)y%t2^7ZV!F z3%yvwD{<~dM+JZW!I^S@B@)5{84{-ouPLU)CaWmtAY&N%pC#1m4O7qYhdUKOFwWG{ z)^7FDJx^$vfh=mE*jsQ+7j=mw(^~A5(e+qyfcngOMSa8mZg-DyP!}UoMbiKA$K9hf z{Kr{G7smc~%`P^zS1(rUtXI~*@!$*U8EXx@Xt`J8#@X!6__CWe5E^J=Bq!f^_*fy+ zovL1c9HW2cbJ!_nr`i+AVUy**3whC8Uneui$u|rIa6W4(&NShKyrMXrztYrpD17?) znN*SYQ#SL43Z@YIJ?3Fmua}W$6~b$R2n13!Cv&Zd_DN*Uf`Q7bJDhPnsW;11k7g?U zR}m}BOhOl0<7W0kQcoT5QjUjPcyMjqNWHfT-=%-DN8ih1@H%e^Bsi$N1aG_vw^WC+ zPCs5PuaOre7OCW>WCq%9_hEMV)Wgd&t@)fm0BD#LepB&7H@DH9wOKsG7yXfXes~?Q zwlBHR-2thRE6@mc^e5fP&NE65H5GU8#uJ=E5iz(o>TKaPz7V&vIyd|^24pwq{ zFMt!phmuoB6~o!DE%29uI0OoOAxZ7q9)RBVq6J29JQ+bCo-Y}m%7jM`2U>!a zI@0Zgus5932HI^G;40ieWM~exz3Q|?Y)a(#H1zkk zxYjHJwjq&&DRsk0J;X?6IyoL0`*2~m3ctKIzd(JL zCp7sg0}H9+^+glTf_~B)wB_kK9d^5y2eUjPE1XV99cRC;lM_s&!pDo{?vl<_EY!K< zZvW#u-r3af&hK63sY3QQP!g55e|qxe4^CH#sNGmSqKMFuJ&>jP?<4KXh>qHZjcHv7 z4b7sXoQ&C###1*Ed2R5vS$oK}W6RygJ!ZYLKY1=hK8qZy;$DLVgEbmw)q-$Z3C+) zFVQ#3Cnm$6p8Rl`G&)!8aR6{;5oaG6sPJ5dKrQGCwoie4ee>WUD(*4)O!ogH>#d`r z+Q0wd0VD(@q&q}Ha_9z0i<0gbLK>8AP(mpQ>5zt@ySrPu8$lSlVTk99_ulXCv)1$e zBlAF@5msc*8AuY8$jb~PngKqlZZsZCPc;ybo&RGBp|q;;~5U6=AaK0I;c^U zA?8iD7Ej%1$qxrKd=HSls;B?xPy-`}7<}tCaVHoc2<>L`^F_Lv0kA_68dLTZ5FC=I zyQ1ohp|4M)Vyj*1!R7|SsV$yB^k)A>MLr>^bBqS{=uh_R7)1b08xP%mgijWFWWd-$ z(1r;aqL`ldjP7SZ`5p47v%GHb&D*!F+0gi}eUCQX*h=PzL0DnnO4b)Iww$DVGd@^Z zdGG=NW9qq;3B7f9gIe;uBEVDwlS`KCeqG+t=vw0fxx&r-!k%;3OfWvFg3!2^5-jL- z4CCDk0FlK`pQmGeiF=wB2W?@aZw0c5t77a3A!0*hdAU9Q%krkqp0}Z>7^vbC*?L!Q zw_ocz>==LM%5-J;b)QQqfo5=$zi)#Sw!2JHX&OjLn;V}u@L!qbMwl&Q4Y z-W}sXesC!fS?{40YKDTTujjg`oFZ0(dqenz;A#8?(c9XbqrCy0#fN)h8@ms8Vclix z7}dq2%1VWuo6{WdK4a%&yKod%_xD#DbeYsP@pnP#z8 zKA#Sx=X=YQKMF17dZ3#3N8woYS`sJCE6EBakU z8Ph~)3nzU6o5ou*h}2{Iez{Yb6&S$oRrbclYqiDIc}S#B_M)Wbk%kE*G>v9DHxQ*C z%?dr=sA~Qf(NlvWaS!m72b^2e0n*CF%RAJbgxs1ENbxy^k80YyXT1Q7haP#!uXEU!DjI z3_Rphy24UFl?fM`$W;&_Y2yR+&?S(tx`Q-A`V75RTHyn%rSB{n{ryqU4;BHo9oVw1 z{#rc^>MQiNQ>ru{o_|COh^9Dmsv->|eY*M-!KY?ir^FdU2(4i48Y(!em1jd=6dFtUJ@mVhvj2^i6!Jar7}L7NSP z?!JHwAw!0m0XJ{XM#DwlwH(^QL~l!tUp8&Wg81#;1&MsVG$k&SOs7UFXwY3!2oS!; zmL^HdTD(gUJRSiStEZ!_Vu5gw@Xo>$Q-?F8v1B*b0P<~DEJ>CcG{0{-1U`-5cU#*! zJcMnaru5>2IQmWZl%}CK9KafLs7-~cJM;R9Be_g~jB*a)qxB`8uABnCqUri#&J~xT?Vo++vfH6CZOhq1tCd`L?Dp&1eLVh-@tkSH1eR# zri?ljI&E1nv8*Y9A79I%gqo`AX*>X+ZXWh+1n%Zwa#B)-FiFLZYb8|XFpyz>EPWCe7;}lIVPeLz{ z?$-sphcbw)>m&f`Ybq<_DulB_$t`Vc^im6xL`QYk1V^Mmb77|>!V#5hntZ4kxw$>) zWuk+hXpxRy9zb{N0YsZ2(yjCEWI;N2j~8xRImW%&3lUHe+7Yhx{t7EA>!}CLla71% zp@c86pnyV+uW9I@&r0!70(ip=#Hj?7S>EB-qqX@%g}M9`JZG!z#h_4Evrqb0 z8C#I?3Y#9F0pYcfkS|6w*t|Z3P10rM*)(a4=4EK|17mJ{d_3-(ocF;Lkdeb|PaPHz zh0Nw}04Ada>?!cP_ECB(LtJQO9Jf-=`ti6)>idZqWiX&7g>&JfS9F;!A`Tag=pyuo zKq@%07H;ysvEMUB+{2M_5!VzHQ*d{RekQ*j7Uh3_no+Smvpy!v-usNPk6!eT0m|p5 zmNX_naqn^i&QPet4N2_>iUomhz6MT4uZU0~&b9<2-eu=J-nLZPLdtRL4%#6xn@>_+ zlxF4ck3#Uz1u3{lapyOL4nzQ{;;*2XHu*{$)y^Ug4*~3e z6LboBfSb&$CYI6=>h!5uc(_g|>fK)Eki|P+99n{EjH`;;!6(!s+?)`JBDpBuj{Poo z767k)Z>wZE=CaSk^f{R45DtPC_gc$1IQuIsU60j-pDXxBsMX&ETQoP* zN~dIYzSp)doSE)XBr9gs)|_Fv!%kB!mP`v49BwUxq{|i&sLqDutH#F1TQmuj36yKS z8$&&;8A=lqsrdMN;F$hI0(!#&>Qr<74$igLWil$TiV3lKq+AGP1+uQKUMs$0)BvG} z6R*#%w^iAZVopv@iWCD+>GJd0fmPb>Gy2!sL6dXv=I1*&<}&*_;$`d-s>XSzpA(|Db{owHJRE)22Jm=Y+e_5}uQ zVXoWqEV-mFOd#5X{9p|h`Ma-s5~85OAW1`f7OUQ^U62hyR557!Ti+na00_GKy!?}U z8(_!03+Y%G7AtAO&b#*|Flx(UF==%sQiz8D4axjXrW|e?BWNMHGdT^>XtTd9@!+2P zS^pxI2`&>U#Q!@BR@&+V%uC4w24_F0ITwXsS4`s!ZLuHGzt4i|%4Acw0jWwU^%Vui z7(Obt(zp8*F-%J0Ky5rm{hR^Rnfy5zt;*S3eJKN)j5H}2!2|e?b%*f7krbiI;f!?8 zKkw65mh}MrAWuX{NSO!C3+cna@89)$qb@)J*~*>RXSyNUlwbfX3M+jc zU$FAI%2fO8;HkYQ;3Glpkd?8F62V)J*89E)CktmX7Pl8}mr+&aR0gG9Yd9dAp3g!$ zQ=MwpPP4Ehj$)sl52rt=fQkdjNRq1BT(9f9t+!M!VK26m5T2>v%4Ou!C?1(jJazW= zl3xKuBYd)^jnytcpyEJ<+inqMZOlR^h{64)0#uR?Tpxts-E@OY_NVr_qDF7^wYx{PX(6sEE_bzdK z#(C)&>d^$HHVz{zfy&MjeGtdn_k3i8<(IT&9ZJ-4*h=$NX8B#t`%_^DgT-gR`@wEX z?#)WBlCF34$O2{qvFRcapN^Rx;vaILgQ^9SGr+QULsKele)p5jr+`mS6osYc`~{rr z%Rgg2OGI3WDp?J+`>5}V*uTTGUy#Gf%FZsPB;ymfXCPMz6ecZ!lw)!|l+_SnmM|bg zjvz(iC~q0lGU!DoQ%Z25mF;PBsTE|X5qk6CQ-Ybi^8)038ZfaZv#RP$5|rl)v`>bd zp?*IXW5+L0RbQSMfGnMFBecwGQUEVT1N*+PlAP5BBr(Ubz+DC5YS`IRmq{2$wVX75 zK#YUDhm~)4bd(rOoI$trkqtUt(6v20@o%9?m3{K=__@7H;YB>B5a}J(f~WxPA+ul1 zfd|ckD!ZZi#PrLgjQk#qIX|W3?PFAT%;ub7PFkj2e8K?DJ#gKMf#3G|qbnNs62@Kf z+dm0(S0^v;M8xn|Y>BX}$&de_>mK)^>#kNGkD;Ng9*5$z&9<}~%xq-=@mAh494U$0 zOVAkGG%|)rP{E+Hd`s+I8$EW1hUxKoE)G1B+TY$2=snyDS&iZh?|g}ZiE?-dWztyB znIcQGR%hE!g-Xn`I_TzTqLOWP;pH_$LMG&$7`iNlt5hFCzJ`3v2blIJtRZV+$H^C; z%&eGdr@_t;)a<=-$WH%)p2gZW6>J_1En#P7zG^#dItPkH6oQF?C&QBks_@1V5!xSb zegNxouur{x!$PpRdt$Y$;rUkfAg&%Dz3ZC4i__~^Q)pETHV+Re;_E3I&3f##KYpvR zq(VR5OFE$>gI2IBIeMB|0^|SM2jnDbk+oogBj&x1fNqKCHc5@=T~JU+6Rii9JK;Qy zS_S|k4h&DpfLxg}=s8q}p1wJGWwWs5a7HAel1efaYNtH)qXcI6I&b29dY+sb!tzQr zPmv0E#}C%~r1)h1`Z0Q;NQ8W??D}G(3u}UksnC*hZ{M#Qg4V^=FhDuh1nY`9%MY*2 zNsErcp3y_XzJ@d0cf z4J570TMi>NrU1Srb@7gq?MaYYx$GvbL}_fXx6+@{UvpnJvEIJ{ayj;QKQs*zao`qx z@&Nt^1rVS=z^lbUzDq>#UxHrES#`Sc&@7@bo-Y)T?aaq0@r7N(*_7QUkH+ z(7 zr=0@DNYF2X{(UA?7W$%x6qFDAqLoVlp@_;MoyN?90?SSyi02Ok$Y24K*$@N}8>LvF zEaJxSOtIFnmJprMi+__72jIB)L}&Jda}|TW6|*ukYvCG6 zYd+@aa|NKMjH!yaE|eCpfFEcXHYYE0^YxYe=&1(<4qw0J-S76=i}7tiD1o_>u02B<8MaF$KHFR`5(nC_xep}fiQfb@SKSdD%25qWhByp7Z^gQk% z$`fQfqNpmcdFUZr7Yh?}I_G`CFt8G-7l0#LGb*MeU^co8&;X{11KfiRAlAD9LX?Gs zS>o{7J1Y(9G6Ewg-8xpx1`jv9=)}gr&1tR7Tf3+aVV=x62RY zaS*jovtVqtu2_QLjRfOvuGFjAimd+i;V0*H_V>h1b&RKX2jB{{U7;|z+NJB1{bV|NAfm8Nif@)BVEKcmskq_T*A%H3oUKWCm!b?E-=bl0Vs>2kcfuHwmYzv}y> z5Zu&V@fMdN1jsjFNQXX4)fml1!{sa~>7-dH==rvfEWz*(@B6mmcMk#xzMZ~zq56})cU4UK^<|Es?#ZPR-}QXD2xA@ zh9-HKN6A*eQgO>-?vb0O_ocmqv?sn8Qa*(WeJcK@nwW2y7wP6-BK>#KM?~@%qo)yr zE@Hio24;Q)zz;TCTW3Q_p{0MTH*@D&g89!q!K9E*3HbwQcK=~2 z{0jzk`Su3xefO+id*gzq{FB^t?x)*f`=3K%;8!&2M%NdF_tiEc2Ojo3yGb^M59YKr z>)5B{Bhi`;nN9vubLX{(o$Z&+f*OCsi>HOGKH%Gly335fM$t!0p`FdA*4!MhArKRZ zrOeous0t+ceo@L7G)8_l!G)=wUd8hG=GQCMQ$E*gpSkjxujuX&#a^HDk+4Cw%^m|g z2rN|?y{Q=1y)PkJ2!2-DMN~Ti`GeM)<6- zLb2hy@MMbPuJQe=p}H1-my19Y_(qyLpId9LqpR*SOsJo9rb+!Uy(Ou@8mN$t-wFmMA26~F@K)b^O41W{ez|G?~Rstnwn41h$ngJDJ3D0aq=G{BOb3! zFZSl50HCywih5=9LK6U)+D%O%M5OnZ6opg8aV0e}qChod5b7aWAbo&1*8xDG>+I{6 zzR&FLXM5jEM}IHzJ)IEougHFqvSYhj8$&bG8%5>b%BznAurxsLL@Whc&Sy`ECmhqu zELID*94R^NQK$c@eL1HMSrV-~!560L&W>IdE8R4FSH0X>$~dpJG-T@}4U2tpwEC0w zXG6$|I*P}d&ja%9YtAk}IvTx|qe8z;$gRhEzxhbLtX4VE88cY3A@HfNym-vIe8@i% zVoE*2Te`~Tj+$k<)N2PTz2K>v@X1kcwDyy45yU~Y(-iNh4!0mOgu}V3BHHG2D#elf zJK9X}tQ_*a{IX1`gEGiv$1&vZMpz%UaZ{x_w$@#!N~`=qOE0}Y>L{MoN*}3u^~C9o zsYOz|K&&C7C|iQBu?zgS6SIjB6p04b1q-bpwJaKhzC zVf$}+Y|N@wND$FRlcV=>WX^(aRqq|mYU#lbhqZk(1%&#s3Oxnd3<1P$66M8NrnEKGQuo}G7f*)qjqYU>r9obtHe8S+5|zl z0({SoW?3WvLFzB~&2&Q8b8};x8};hWSLeyeWeqh2-P|al8$%*jMWUZ8e?LEqYT&p1 zR6_l2=RT5$qY+?Wa5nS!T0>(1EM5Pzvq+LowX2I40QO_*R-OwZ7}ld?IRz`%95kdI zc9{?ce0e~*a?q)w5i7z-m-xA2Fm2V!Xb6T)os-2sc2MfdUAfD)#b%Ef=(%BxP*d?C zSQCHq4^RHRiUl0PSDr?#+f1uWJ)fQzs)r)9K|{RS3+E&-W8tq?APt%O=$L?i?#u}W zpq%p+h>OyXCaUumg- ze5i3~Q+>;&9dbHM=_QxeX;jc}wE%O*V7vjlkp-FOrEpR9;*9RP&vU>yX>4pSk2ybh zSK(w0c~HIx<{NNG#LDJ@0e z;ZFemfI*n;#}vuV$bI0rwKZg<)e9yU`>MY|*ry+1-2OW%a)5 MX4XGow27001U; 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 VwHRK1-@o}=fVjL^sfgjn{{yP=_5%O_ literal 0 HcmV?d00001 From 6d87f1ac0e3acae8d8fc3b2555a7b52e77b102fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Thu, 30 May 2019 20:38:50 +0200 Subject: [PATCH 051/222] Feature/logo (#114) Correct logo in README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index ab44ce80d..24bc67654 100644 --- a/README.rst +++ b/README.rst @@ -1,4 +1,4 @@ -.. image:: https://raw.githubusercontent.com/GAA-UAM/scikit-fda/develop/docs/logos/title_logo/title_logo.png?sanitize=true&raw=true +.. image:: https://raw.githubusercontent.com/GAA-UAM/scikit-fda/develop/docs/logos/title_logo/title_logo.png :alt: scikit-fda: Functional Data Analysis in Python scikit-fda From 73242481784e83388484f2c8b933cea0005a97c2 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 4 Jun 2019 14:48:14 +0200 Subject: [PATCH 052/222] Alias of smoothing functions --- examples/plot_kernel_smoothing.py | 6 +- skfda/_utils.py | 27 +++++++++ .../smoothing/kernel_smoothers.py | 60 ++++++++++++------- skfda/preprocessing/smoothing/validation.py | 10 ++-- 4 files changed, 73 insertions(+), 30 deletions(-) diff --git a/examples/plot_kernel_smoothing.py b/examples/plot_kernel_smoothing.py index e15050b4a..7bb52d3e5 100644 --- a/examples/plot_kernel_smoothing.py +++ b/examples/plot_kernel_smoothing.py @@ -44,7 +44,7 @@ smoothing_method=ks.local_linear_regression) # Nadaraya-Watson kernel smoothing. nw = skfda.preprocessing.smoothing.validation.minimise( - fd, param_values, smoothing_method=ks.nw) + fd, param_values, smoothing_method=ks.nadaraya_watson) # K-nearest neighbours kernel smoothing. knn = skfda.preprocessing.smoothing.validation.minimise( @@ -100,11 +100,11 @@ # the following plots. fd_us = skfda.FDataGrid( - ks.nw(fd.sample_points, h=2).dot(fd.data_matrix[10, ..., 0]), + ks.nadaraya_watson(fd.sample_points, h=2).dot(fd.data_matrix[10, ..., 0]), fd.sample_points, fd.sample_range, fd.dataset_label, fd.axes_labels) fd_os = skfda.FDataGrid( - ks.nw(fd.sample_points, h=15).dot(fd.data_matrix[10, ..., 0]), + ks.nadaraya_watson(fd.sample_points, h=15).dot(fd.data_matrix[10, ..., 0]), fd.sample_points, fd.sample_range, fd.dataset_label, fd.axes_labels) diff --git a/skfda/_utils.py b/skfda/_utils.py index d2d173650..644c7dfb7 100644 --- a/skfda/_utils.py +++ b/skfda/_utils.py @@ -1,6 +1,7 @@ """Module with generic methods""" import numpy as np +import functools def _list_of_arrays(original_array): @@ -65,3 +66,29 @@ def _coordinate_list(axes): """ return np.vstack(list(map(np.ravel, np.meshgrid(*axes, indexing='ij')))).T + + +def parameter_aliases(**alias_assignments): + """Allows using aliases for parameters""" + def decorator(f): + @functools.wraps(f) + def aliasing_function(*args, **kwargs): + nonlocal alias_assignments + for parameter_name, aliases in alias_assignments.items(): + aliases = tuple(aliases) + aliases_used = [a for a in kwargs + if a in aliases + (parameter_name,)] + if len(aliases_used) > 1: + raise ValueError( + f"Several arguments with the same meaning used: " + + str(aliases_used)) + + elif len(aliases_used) == 1: + arg = kwargs.pop(aliases_used[0]) + kwargs[parameter_name] = arg + + return f(*args, **kwargs) + + return aliasing_function + + return decorator diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index b9f656ce1..42c6b97c6 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -3,7 +3,7 @@ This module includes the most commonly used kernel smoother methods for FDA. So far only non parametric methods are implemented because we are only - relaying on a discrete representation of functional data. + relying on a discrete representation of functional data. Todo: * Closed-form for KNN @@ -14,13 +14,15 @@ import numpy as np from ...misc import kernels - +from ..._utils import parameter_aliases __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" -def nw(argvals, h=None, kernel=kernels.normal, w=None, cv=False): +@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) +def nadaraya_watson(argvals, *, smoothing_parameter=None, + kernel=kernels.normal, w=None, cv=False): r"""Nadaraya-Watson smoothing method. Provides an smoothing matrix :math:`\hat{H}` for the discretisation @@ -35,25 +37,28 @@ def nw(argvals, h=None, kernel=kernels.normal, w=None, cv=False): \frac{x_1-x_k}{h}\right)} where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel - window width. + window width or smoothing parameter. Args: argvals (ndarray): Vector of discretisation points. - h (float, optional): Window width of the kernel. + smoothing_parameter (float, optional): Window width of the kernel. kernel (function, optional): kernel function. By default a normal kernel. w (ndarray, optional): Case weights matrix. cv (bool, optional): Flag for cross-validation methods. Defaults to False. + h (float, optional): same as smoothing_parameter. + bandwidth (float, optional): same as smoothing_parameter. Examples: - >>> nw(np.array([1,2,4,5,7]), 3.5).round(3) + >>> nadaraya_watson(np.array([1,2,4,5,7]), + ... smoothing_parameter=3.5).round(3) array([[ 0.294, 0.282, 0.204, 0.153, 0.068], [ 0.249, 0.259, 0.22 , 0.179, 0.093], [ 0.165, 0.202, 0.238, 0.229, 0.165], [ 0.129, 0.172, 0.239, 0.249, 0.211], [ 0.073, 0.115, 0.221, 0.271, 0.319]]) - >>> nw(np.array([1,2,4,5,7]), 2).round(3) + >>> nadaraya_watson(np.array([1,2,4,5,7]), h=2).round(3) array([[ 0.425, 0.375, 0.138, 0.058, 0.005], [ 0.309, 0.35 , 0.212, 0.114, 0.015], [ 0.103, 0.193, 0.319, 0.281, 0.103], @@ -65,11 +70,11 @@ def nw(argvals, h=None, kernel=kernels.normal, w=None, cv=False): """ delta_x = np.abs(np.subtract.outer(argvals, argvals)) - if h is None: - h = np.percentile(delta_x, 15) + if smoothing_parameter is None: + smoothing_parameter = np.percentile(delta_x, 15) if cv: np.fill_diagonal(delta_x, math.inf) - delta_x = delta_x / h + delta_x = delta_x / smoothing_parameter k = kernel(delta_x) if w is not None: k = k * w @@ -78,7 +83,9 @@ def nw(argvals, h=None, kernel=kernels.normal, w=None, cv=False): return (k.T / rs).T -def local_linear_regression(argvals, h, kernel=kernels.normal, w=None, +@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) +def local_linear_regression(argvals, smoothing_parameter, *, + kernel=kernels.normal, w=None, cv=False): r"""Local linear regression smoothing method. @@ -103,12 +110,14 @@ def local_linear_regression(argvals, h, kernel=kernels.normal, w=None, Args: argvals (ndarray): Vector of discretisation points. - h (float, optional): Window width of the kernel. + smoothing_parameter (float, optional): Window width of the kernel. kernel (function, optional): kernel function. By default a normal kernel. w (ndarray, optional): Case weights matrix. cv (bool, optional): Flag for cross-validation methods. Defaults to False. + h (float, optional): same as smoothing_parameter. + bandwidth (float, optional): same as smoothing_parameter. Examples: >>> local_linear_regression(np.array([1,2,4,5,7]), 3.5).round(3) @@ -132,7 +141,7 @@ def local_linear_regression(argvals, h, kernel=kernels.normal, w=None, delta_x = np.abs(np.subtract.outer(argvals, argvals)) # x_i - x_j if cv: np.fill_diagonal(delta_x, math.inf) - k = kernel(delta_x / h) # K(x_i - x/ h) + k = kernel(delta_x / smoothing_parameter) # K(x_i - x/ h) s1 = np.sum(k * delta_x, 1) # S_n_1 s2 = np.sum(k * delta_x ** 2, 1) # S_n_2 b = (k * (s2 - delta_x * s1)).T # b_i(x_j) @@ -144,7 +153,9 @@ def local_linear_regression(argvals, h, kernel=kernels.normal, w=None, return (b.T / rs).T # \\hat{H} -def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): +@parameter_aliases(smoothing_parameter=['k', 'n_neighbors']) +def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, + w=None, cv=False): """K-nearest neighbour kernel smoother. Provides an smoothing matrix S for the discretisation points in argvals by @@ -157,19 +168,21 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): Args: argvals (ndarray): Vector of discretisation points. - k (int, optional): Number of nearest neighbours. By default it takes - the 5% closest points. + smoothing_parameter (int, optional): Number of nearest neighbours. By + default it takes the 5% closest points. kernel (function, optional): kernel function. By default a uniform kernel to perform a 'usual' k nearest neighbours estimation. w (ndarray, optional): Case weights matrix. cv (bool, optional): Flag for cross-validation methods. Defaults to False. + k (float, optional): same as smoothing_parameter. + n_neighbors (float, optional): same as smoothing_parameter. Returns: ndarray: Smoothing matrix. Examples: - >>> knn(np.array([1,2,4,5,7]), 2) + >>> knn(np.array([1,2,4,5,7]), smoothing_parameter=2) array([[ 0.5, 0.5, 0. , 0. , 0. ], [ 0.5, 0.5, 0. , 0. , 0. ], [ 0. , 0. , 0.5, 0.5, 0. ], @@ -178,7 +191,7 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): In case there are two points at the same distance it will take both. - >>> knn(np.array([1,2,3,5,7]), 2).round(3) + >>> knn(np.array([1,2,3,5,7]), k=2).round(3) array([[ 0.5 , 0.5 , 0. , 0. , 0. ], [ 0.333, 0.333, 0.333, 0. , 0. ], [ 0. , 0.5 , 0.5 , 0. , 0. ], @@ -190,9 +203,10 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): # Distances matrix of points in argvals delta_x = np.abs(np.subtract.outer(argvals, argvals)) - if k is None: - k = np.floor(np.percentile(range(1, len(argvals)), 5)) - elif k <= 0: + if smoothing_parameter is None: + smoothing_parameter = np.floor(np.percentile( + range(1, len(argvals)), 5)) + elif smoothing_parameter <= 0: raise ValueError('h must be greater than 0') if cv: np.fill_diagonal(delta_x, math.inf) @@ -203,8 +217,8 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): # For each row in the distances matrix, it calculates the furthest point # within the k nearest neighbours - vec = np.percentile(delta_x, k / len(argvals) * 100, axis=0, - interpolation='lower') + tol + vec = np.percentile(delta_x, smoothing_parameter / len(argvals) * 100, + axis=0, interpolation='lower') + tol rr = kernel((delta_x.T / vec).T) # Applies the kernel to the result of dividing each row by the result diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 6018f1866..2fdfce698 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -83,7 +83,7 @@ def gcv(fdatagrid, s_matrix, penalisation_function=None): def minimise(fdatagrid, parameters, - smoothing_method=kernel_smoothers.nw, cv_method=gcv, + smoothing_method=kernel_smoothers.nadaraya_watson, cv_method=gcv, penalisation_function=None, **kwargs): """Chooses the best smoothness parameter and performs smoothing. @@ -191,18 +191,20 @@ def minimise(fdatagrid, parameters, # Calculates the scores for each parameter. if penalisation_function is not None: for h in parameters: - s = smoothing_method(sample_points, h, **kwargs) + s = smoothing_method(sample_points, smoothing_parameter=h, + **kwargs) scores.append( cv_method(fdatagrid, s, penalisation_function=penalisation_function)) else: for h in parameters: - s = smoothing_method(sample_points, h, **kwargs) + s = smoothing_method(sample_points, smoothing_parameter=h, + **kwargs) scores.append( cv_method(fdatagrid, s)) # gets the best parameter. h = parameters[int(np.argmin(scores))] - s = smoothing_method(sample_points, h, **kwargs) + s = smoothing_method(sample_points, smoothing_parameter=h, **kwargs) fdatagrid_adjusted = fdatagrid.copy( data_matrix=np.dot(fdatagrid.data_matrix[..., 0], s.T)) From c7c71c7e86acf7d584847611d247040e6f291bf3 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Tue, 4 Jun 2019 17:20:01 +0200 Subject: [PATCH 053/222] Updated the scalar regression to match sklearn api --- skfda/ml/regression/scalar.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/skfda/ml/regression/scalar.py b/skfda/ml/regression/scalar.py index 522703cfc..79764119b 100644 --- a/skfda/ml/regression/scalar.py +++ b/skfda/ml/regression/scalar.py @@ -1,26 +1,27 @@ from sklearn.metrics import mean_squared_error +from sklearn.base import BaseEstimator, RegressorMixin from skfda.representation.basis import * import numpy as np -class ScalarRegression: +class ScalarRegression(BaseEstimator, RegressorMixin): def __init__(self, beta, weights=None): self.beta = beta self.weights = weights - def fit(self, y, x): + def fit(self, X, y=None): - y, x, beta, wt = self._argcheck(y, x) + y, X, beta, wt = self._argcheck(y, X) nbeta = len(beta) - nsamples = x[0].nsamples + nsamples = X[0].nsamples y = np.array(y).reshape((nsamples, 1)) for j in range(nbeta): - xcoef = x[j].coefficients - xbasis = x[j].basis + xcoef = X[j].coefficients + xbasis = X[j].basis Jpsithetaj = xbasis.inner_product(beta[j]) Zmat = xcoef @ Jpsithetaj if j == 0 else np.concatenate( (Zmat, xcoef @ Jpsithetaj), axis=1) @@ -46,9 +47,9 @@ def fit(self, y, x): self.beta = beta - def predict(self, x): - return [sum(self.beta[i].inner_product(x[i][j])[0, 0] for i in - range(len(self.beta))) for j in range(x[0].nsamples)] + def predict(self, X) + return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in + range(len(self.beta))) for j in range(X[0].nsamples)] def mean_squared_error(self, y_actual, y_predicted): return np.sqrt(mean_squared_error(y_actual, y_predicted)) @@ -94,3 +95,8 @@ def _argcheck(self, y, x): raise ValueError("The weights should be non negative values") return y, x, self.beta, self.weights + + def score(self, X, y, sample_weight=None): + pass + + From d46079fd375c66888da88e1f4b24cdf4d99b3fba Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Wed, 5 Jun 2019 23:08:03 +0200 Subject: [PATCH 054/222] Finishing the new implementation for inner product with gramian matrix --- skfda/representation/basis.py | 73 +++++++++++++++++++++++++++++++---- tests/test_basis.py | 39 ++++++++++++++----- 2 files changed, 96 insertions(+), 16 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index f82703249..c29305e9e 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -325,6 +325,70 @@ def _list_to_R(self, knots): def _to_R(self): raise NotImplementedError + def inner_matrix(self, other=None): + + r"""Return the Inner Product Matrix of a pair of basis. + + The Inner Product Matrix is defined as + + .. math:: + IP_{ij} = \langle\phi_i, \theta_j\rangle + + where :math:`\phi_i` is the ith element of the basi and + :math:`\theta_j` is the jth element of the second basis. + This matrix helps on the calculation of the inner product + between objects on two basis and for the change of basis. + + Args: + other (:class:`Basis`): Basis to compute the inner product + matrix. If not basis is given, it computes the matrix with + itself returning the Gramian Matrix + + Returns: + numpy.array: Inner Product Matrix of two basis + + """ + if other is None or self == other: + return self.gramian_matrix() + + first = self.to_basis() + second = other.to_basis() + + gramian = np.zeros((self.nbasis, other.nbasis)) + + for i in range(self.nbasis): + for j in range(other.nbasis): + gramian[i, j] = first[i].inner_product(second[j], None, None) + + return gramian + + def gramian_matrix(self): + + r"""Return the Gramian Matrix of a basis + + The Gramian Matrix is defined as + + .. math:: + G_{ij} = \langle\phi_i, \phi_j\rangle + + where :math:`\phi_i` is the ith element of the basis. This is a symmetric matrix and + positive-semidefinite. + + Returns: + numpy.array: Gramian Matrix of the basis. + + """ + fbasis = self.to_basis() + + gramian = np.zeros((self.nbasis, self.nbasis)) + + for i in range(fbasis.nbasis): + for j in range(i, fbasis.nbasis): + gramian[i, j] = fbasis[i].inner_product(fbasis[j], None, None) + gramian[j, i] = gramian[i, j] + + return gramian + def inner_product(self, other): return np.transpose(other.inner_product(self.to_basis())) @@ -2176,16 +2240,10 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, other = other.times(weights) if self.nsamples * other.nsamples > self.nbasis * other.nbasis: - return self._inner_product_gramm_matrix(other, lfd_self, lfd_other) + return self.coefficients @ self.basis.inner_matrix(other.basis) @ other.coefficients.T else: return self._inner_product_integrate(other, lfd_self, lfd_other) - - def _inner_product_gramm_matrix(self, other, lfd_self, lfd_other): - - return self.coefficients @ self.basis.inner_product(other.basis) @ other.coefficients.T - - def _inner_product_integrate(self, other, lfd_self, lfd_other): matrix = np.empty((self.nsamples, other.nsamples)) @@ -2196,6 +2254,7 @@ def _inner_product_integrate(self, other, lfd_self, lfd_other): fd = self[i].times(other[j]) matrix[i, j] = scipy.integrate.quad( lambda x: fd.evaluate([x])[0], left, right)[0] + return matrix def _to_R(self): diff --git a/tests/test_basis.py b/tests/test_basis.py index 2bbbf81a6..4a3c27d54 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -127,6 +127,24 @@ def test_basis_bspline_product(self): prod = BSpline(domain_range=(0,1), nbasis=10, order=7, knots=[0, 0.3, 1/3, 2/3,1]) self.assertEqual(bspline.basis_of_product(bspline2), prod) + def test_basis_inner_matrix(self): + np.testing.assert_array_almost_equal(Monomial(nbasis=3).inner_matrix(), + [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) + + np.testing.assert_array_almost_equal(Monomial(nbasis=3).inner_matrix(Monomial(nbasis=3)), + [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) + + np.testing.assert_array_almost_equal(Monomial(nbasis=3).inner_matrix(Monomial(nbasis=4)), + [[1, 1/2, 1/3, 1/4], [1/2, 1/3, 1/4, 1/5], [1/3, 1/4, 1/5, 1/6]]) + + # TODO testing with other basis + + def test_basis_gramian_matrix(self): + np.testing.assert_array_almost_equal(Monomial(nbasis=3).gramian_matrix(), + [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) + + # TODO testing with other basis + def test_basis_basis_inprod(self): monomial = Monomial(nbasis=4) bspline = BSpline(nbasis=5, order=4) @@ -139,6 +157,10 @@ def test_basis_basis_inprod(self): [0.00044654, 0.01339264, 0.04375022, 0.09910693, 0.09330368]]) .round(3) ) + np.testing.assert_array_almost_equal( + monomial.inner_product(bspline), + bspline.inner_product(monomial).T + ) def test_basis_fdatabasis_inprod(self): monomial = Monomial(nbasis=4) @@ -172,20 +194,19 @@ def test_fdatabasis_fdatabasis_inprod(self): [19.70392982, 63.03676315, 106.37009648]]).round(3) ) - def test_comutativity_inprod(self): - monomial = Monomial(nbasis=4) - bspline = BSpline(nbasis=5, order=3) - bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) - np.testing.assert_array_almost_equal( - bsplinefd.inner_product(monomial).round(3), - np.transpose(monomial.inner_product(bsplinefd).round(3)) + monomialfd._inner_product_integrate(bsplinefd, None, None).round(3), + np.array([[16.14797697, 52.81464364, 89.4813103], + [11.55565285, 38.22211951, 64.88878618], + [18.14698361, 55.64698361, 93.14698361], + [15.2495976, 48.9995976, 82.7495976], + [19.70392982, 63.03676315, 106.37009648]]).round(3) ) - def test_gram_inprod(self): + def test_comutativity_inprod(self): monomial = Monomial(nbasis=4) bspline = BSpline(nbasis=5, order=3) - bsplinefd = FDataBasis(bspline, np.arange(0, 3000).reshape(600, 5)) + bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) np.testing.assert_array_almost_equal( bsplinefd.inner_product(monomial).round(3), From c4728c51ed1c118eaf704a06d19af0f74d8ef500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Thu, 6 Jun 2019 12:04:38 +0200 Subject: [PATCH 055/222] Pandas integration (#115) * First version FDataGrid inside Pandas * Integrate FDataBasis with Pandas * Correct K-means bug * Correct K-means bug --- skfda/ml/clustering/base_kmeans.py | 2 +- skfda/representation/_functional_data.py | 130 ++++++++++++++- skfda/representation/basis.py | 38 +++++ skfda/representation/grid.py | 192 +++++++++++++---------- 4 files changed, 274 insertions(+), 88 deletions(-) diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index d0f0706fc..cc0bba08e 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -664,7 +664,7 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): for i in range(fdatagrid.nsamples): comparison = (fdatagrid.data_matrix[i] == centers).all( - axis=tuple(np.arange(fdatagrid.ndim)[1:])) + axis=tuple(np.arange(fdatagrid.data_matrix.ndim)[1:])) if comparison.sum() >= 1: U[i, np.where(comparison == True)] = 1 U[i, np.where(comparison == False)] = 0 diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index ebf8814e5..f8a8f3af1 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -13,12 +13,13 @@ import matplotlib.pyplot as plt from matplotlib.axes import Axes import mpl_toolkits.mplot3d +import pandas.api.extensions from skfda.representation.extrapolation import _parse_extrapolation from .._utils import _coordinate_list, _list_of_arrays -class FData(ABC): +class FData(ABC, pandas.api.extensions.ExtensionArray): """Defines the structure of a functional data object. Attributes: @@ -1077,7 +1078,6 @@ def __rtruediv__(self, other): pass - def __iter__(self): """Iterate over the samples""" @@ -1088,3 +1088,129 @@ def __len__(self): """Returns the number of samples of the FData object.""" return self.nsamples + + ##################################################################### + # Numpy methods + ##################################################################### + + def to_numpy(self): + """Returns a numpy array with the objects""" + + # This is to prevent numpy to access inner dimensions + array = np.empty(shape=len(self), dtype=np.object_) + + for i, f in enumerate(self): + array[i] = f + + return array + + def __array__(self, dtype=None): + """Automatic conversion to numpy array""" + return self.to_numpy() + + ##################################################################### + # Pandas ExtensionArray methods + ##################################################################### + @property + def ndim(self): + """ + Return number of dimensions of the functional data. It is + always 1, as each observation is considered a "scalar" object. + + Returns: + int: Number of dimensions of the functional data. + + """ + return 1 + + @classmethod + def _from_sequence(cls, scalars, dtype=None, copy=False): + return cls(scalars, dtype=dtype) + + @classmethod + def _from_factorized(cls, values, original): + return cls(values) + + def isna(self): + """ + A 1-D array indicating if each value is missing. + + Returns: + na_values (np.ndarray): Array full of True values. + """ + return np.ones(self.nsamples, dtype=bool) + + def take(self, indices, allow_fill=False, fill_value=None): + """ + Take elements from an array. + Parameters: + indices (sequence of integers): + Indices to be taken. + allow_fill (bool, default False): How to handle negative values + in `indices`. + * False: negative values in `indices` indicate positional + indices from the right (the default). This is similar to + :func:`numpy.take`. + * True: negative values in `indices` indicate + missing values. These values are set to `fill_value`. Any + other negative values raise a ``ValueError``. + fill_value (any, optional): + Fill value to use for NA-indices when `allow_fill` is True. + This may be ``None``, in which case the default NA value for + the type, ``self.dtype.na_value``, is used. + For many ExtensionArrays, there will be two representations of + `fill_value`: a user-facing "boxed" scalar, and a low-level + physical NA value. `fill_value` should be the user-facing + version, and the implementation should handle translating that + to the physical version for processing the take if necessary. + Returns: + FData + Raises: + IndexError: When the indices are out of bounds for the array. + ValueError: When `indices` contains negative values other than + ``-1`` and `allow_fill` is True. + Notes: + ExtensionArray.take is called by ``Series.__getitem__``, ``.loc``, + ``iloc``, when `indices` is a sequence of values. Additionally, + it's called by :meth:`Series.reindex`, or any other method + that causes realignment, with a `fill_value`. + See Also: + numpy.take + pandas.api.extensions.take + """ + from pandas.core.algorithms import take + # If the ExtensionArray is backed by an ndarray, then + # just pass that here instead of coercing to object. + data = self.astype(object) + if allow_fill and fill_value is None: + fill_value = self.dtype.na_value + # fill value should always be translated from the scalar + # type for the array, to the physical storage type for + # the data, before passing to take. + result = take(data, indices, fill_value=fill_value, + allow_fill=allow_fill) + return self._from_sequence(result, dtype=self.dtype) + + @classmethod + def _concat_same_type( + cls, + to_concat + ): + """ + Concatenate multiple array + + Parameters: + to_concat (sequence of FData) + Returns: + FData + """ + + first, *others = to_concat + + for o in others: + first = first.concatenate(o) + + # When #101 is ready + # return first.concatenate(others) + + return first diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 569f6b6af..ad82b52f2 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -19,6 +19,7 @@ from . import grid from . import FData from .._utils import _list_of_arrays +import pandas.api.extensions __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" @@ -2358,3 +2359,40 @@ def __rtruediv__(self, other): """Right division for FDataBasis object.""" raise NotImplementedError + + ##################################################################### + # Pandas ExtensionArray methods + ##################################################################### + @property + def dtype(self): + """The dtype for this extension array, FDataGridDType""" + return FDataBasisDType + + @property + def nbytes(self) -> int: + """ + The number of bytes needed to store this object in memory. + """ + return self.coefficients.nbytes() + + +class FDataBasisDType(pandas.api.extensions.ExtensionDtype): + """ + DType corresponding to FDataBasis in Pandas + """ + name = 'functional data (basis)' + kind = 'O' + type = FDataBasis + na_value = None + + @classmethod + def construct_from_string(cls, string): + if string == cls.name: + return cls() + else: + raise TypeError("Cannot construct a '{}' from " + "'{}'".format(cls, string)) + + @classmethod + def construct_array_type(cls): + return FDataBasis diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index e8fa5e72c..e28b29abf 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -11,6 +11,7 @@ import copy import numpy as np import scipy.stats.mstats +import pandas.api.extensions from . import basis as fdbasis @@ -108,8 +109,8 @@ def __init__(self, data_matrix, sample_points=None, values of a functional datum evaluated at the points of discretisation. sample_points (array_like, optional): an array containing the - points of discretisation where values have been recorded or a list - of lists with each of the list containing the points of + points of discretisation where values have been recorded or a + list of lists with each of the list containing the points of dicretisation for each axis. domain_range (tuple or list of tuples, optional): contains the edges of the interval in which the functional data is @@ -118,9 +119,9 @@ def __init__(self, data_matrix, sample_points=None, the domain). dataset_label (str, optional): name of the dataset. axes_labels (list, optional): list containing the labels of the - different axes. The length of the list must be equal to the sum of the - number of dimensions of the domain plus the number of dimensions - of the image. + different axes. The length of the list must be equal to the + sum of the number of dimensions of the domain plus the number + of dimensions of the image. """ self.data_matrix = np.atleast_2d(data_matrix) @@ -145,7 +146,6 @@ def __init__(self, data_matrix, sample_points=None, "points have shape {}" .format(data_shape, sample_points_shape)) - self._sample_range = np.array( [(self.sample_points[i][0], self.sample_points[i][-1]) for i in range(self.ndim_domain)]) @@ -158,7 +158,8 @@ def __init__(self, data_matrix, sample_points=None, self._domain_range = np.atleast_2d(domain_range) # sample range must by a 2 dimension matrix with as many rows as # dimensions in the domain and 2 columns - if (self._domain_range.ndim != 2 or self._domain_range.shape[1] != 2 + if (self._domain_range.ndim != 2 + or self._domain_range.shape[1] != 2 or self._domain_range.shape[0] != self.ndim_domain): raise ValueError("Incorrect shape of domain_range.") for i in range(self.ndim_domain): @@ -172,16 +173,15 @@ def __init__(self, data_matrix, sample_points=None, if self.data_matrix.ndim == 1 + self.ndim_domain: self.data_matrix = self.data_matrix[..., np.newaxis] - if axes_labels is not None and len(axes_labels) != (self.ndim_domain + self.ndim_image): + if axes_labels is not None and len(axes_labels) != (self.ndim_domain + + self.ndim_image): raise ValueError("There must be a label for each of the" - "dimensions of the domain and the image.") + "dimensions of the domain and the image.") self.interpolator = interpolator - super().__init__(extrapolation, dataset_label, axes_labels, keepdims) - return def round(self, decimals=0): @@ -227,16 +227,6 @@ def ndim_image(self): except IndexError: return 1 - @property - def ndim(self): - """Return number of dimensions of the data matrix. - - Returns: - int: Number of dimensions of the data matrix. - - """ - return self.data_matrix.ndim - @property def nsamples(self): """Return number of rows of the data_matrix. Also the number of samples. @@ -313,7 +303,6 @@ def _evaluator(self): return self._interpolator_evaluator - def _evaluate(self, eval_points, *, derivative=0): """"Evaluate the object or its derivatives at a list of values. @@ -487,7 +476,7 @@ def cov(self): dataset_label = None return self.copy(data_matrix=np.cov(self.data_matrix, - rowvar=False)[np.newaxis, ...], + rowvar=False)[np.newaxis, ...], sample_points=[self.sample_points[0], self.sample_points[0]], domain_range=[self.domain_range[0], @@ -503,8 +492,8 @@ def gmean(self): FDataGrid object. """ - return self.copy(data_matrix= - [scipy.stats.mstats.gmean(self.data_matrix, 0)]) + return self.copy(data_matrix=[ + scipy.stats.mstats.gmean(self.data_matrix, 0)]) def __add__(self, other): """Addition for FDataGrid object. @@ -619,7 +608,6 @@ def __rtruediv__(self, other): return self.copy(data_matrix=data_matrix / self.data_matrix) - def concatenate(self, other): """Join samples from a similar FDataGrid object. @@ -658,25 +646,30 @@ def concatenate(self, other): self.__check_same_dimensions(other) return self.copy(data_matrix=np.concatenate((self.data_matrix, - other.data_matrix), - axis=0)) - + other.data_matrix), + axis=0)) def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): """Scatter plot of the FDatGrid object. Args: - fig (figure object, optional): figure over with the graphs are plotted in case ax is not specified. - If None and ax is also None, the figure is initialized. - ax (list of axis objects, optional): axis over where the graphs are plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure to plot the different dimensions of the - image. Only specified if fig and ax are None. - ncols(int, optional): designates the number of columns of the figure to plot the different dimensions of the - image. Only specified if fig and ax are None. - **kwargs: keyword arguments to be passed to the matplotlib.pyplot.scatter function; + fig (figure object, optional): figure over with the graphs are + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. + ax (list of axis objects, optional): axis over where the graphs + are plotted. If None, see param fig. + nrows(int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + ncols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. + **kwargs: keyword arguments to be passed to the + matplotlib.pyplot.scatter function; Returns: - fig (figure object): figure object in which the graphs are plotted in case ax is None. + fig (figure object): figure object in which the graphs are plotted + in case ax is None. ax (axes object): axes in which the graphs are plotted. """ @@ -685,20 +678,21 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): if self.ndim_domain == 1: for i in range(self.ndim_image): for j in range(self.nsamples): - ax[i].scatter(self.sample_points[0], self.data_matrix[j, :, i].T, **kwargs) + ax[i].scatter(self.sample_points[0], + self.data_matrix[j, :, i].T, **kwargs) else: X = self.sample_points[0] Y = self.sample_points[1] X, Y = np.meshgrid(X, Y) for i in range(self.ndim_image): for j in range(self.nsamples): - ax[i].scatter(X, Y, self.data_matrix[j, :, :, i].T, **kwargs) + ax[i].scatter(X, Y, self.data_matrix[j, :, :, i].T, + **kwargs) self.set_labels(fig, ax) return fig, ax - def to_basis(self, basis, **kwargs): """Return the basis representation of the object. @@ -743,8 +737,8 @@ def to_grid(self, sample_points=None): """Return the discrete representation of the object. Args: - sample_points (array_like, optional): 2 dimension matrix where each - row contains the points of dicretisation for each axis of + sample_points (array_like, optional): 2 dimension matrix where + each row contains the points of dicretisation for each axis of data_matrix. Returns: @@ -758,12 +752,12 @@ def to_grid(self, sample_points=None): return self.copy(data_matrix=self.evaluate(sample_points, grid=True), sample_points=sample_points) - - - def copy(self, *, data_matrix=None, sample_points=None, - domain_range=None, dataset_label=None, - axes_labels=None, extrapolation=None, - interpolator=None, keepdims=None): + def copy(self, *, + deep=False, # For Pandas compatibility + data_matrix=None, sample_points=None, + domain_range=None, dataset_label=None, + axes_labels=None, extrapolation=None, + interpolator=None, keepdims=None): """Returns a copy of the FDataGrid. If an argument is provided the corresponding attribute in the new copy @@ -798,10 +792,10 @@ def copy(self, *, data_matrix=None, sample_points=None, keepdims = self.keepdims return FDataGrid(data_matrix, sample_points=sample_points, - domain_range=domain_range, dataset_label=dataset_label, - axes_labels=axes_labels, extrapolation=extrapolation, - interpolator=interpolator, keepdims=keepdims) - + domain_range=domain_range, + dataset_label=dataset_label, + axes_labels=axes_labels, extrapolation=extrapolation, + interpolator=interpolator, keepdims=keepdims) def shift(self, shifts, *, restrict_domain=False, extrapolation=None, eval_points=None): @@ -809,8 +803,8 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, Args: shifts (array_like or numeric): List with the shifts - corresponding for each sample or numeric with the shift to apply - to all samples. + corresponding for each sample or numeric with the shift to + apply to all samples. restrict_domain (bool, optional): If True restricts the domain to avoid evaluate points outside the domain using extrapolation. Defaults uses extrapolation. @@ -828,7 +822,6 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, :class:`FDataGrid` with the shifted data. """ - if np.isscalar(shifts): shifts = [shifts] @@ -839,7 +832,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, shifts = shifts[:, np.newaxis] # Case same shift for all the curves - if shifts.shape[0] == self.ndim_domain and shifts.ndim ==1: + if shifts.shape[0] == self.ndim_domain and shifts.ndim == 1: # Column vector with shapes shifts = np.atleast_2d(shifts).T @@ -849,28 +842,24 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, return self.copy(sample_points=sample_points, domain_range=domain_range) - - if shifts.shape[0] != self.nsamples: - raise ValueError(f"shifts vector ({shifts.shape[0]}) must have the " - f"same length than the number of samples " + raise ValueError(f"shifts vector ({shifts.shape[0]}) must have the" + f" same length than the number of samples " f"({self.nsamples})") if eval_points is None: eval_points = self.sample_points - - if restrict_domain: domain = np.asarray(self.domain_range) - a = domain[:,0] - np.atleast_1d(np.min(np.min(shifts, axis=1), 0)) - b = domain[:,1] - np.atleast_1d(np.max(np.max(shifts, axis=1), 0)) + a = domain[:, 0] - np.atleast_1d(np.min(np.min(shifts, axis=1), 0)) + b = domain[:, 1] - np.atleast_1d(np.max(np.max(shifts, axis=1), 0)) - domain = np.vstack((a,b)).T + domain = np.vstack((a, b)).T eval_points = [eval_points[i][ - np.logical_and(eval_points[i] >= domain[i,0], - eval_points[i] <= domain[i,1])] + np.logical_and(eval_points[i] >= domain[i, 0], + eval_points[i] <= domain[i, 1])] for i in range(self.ndim_domain)] else: @@ -878,28 +867,23 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, eval_points = np.asarray(eval_points) - eval_points_repeat = np.repeat(eval_points[np.newaxis, :], self.nsamples, axis=0) # Solve problem with cartesian and matrix indexing if self.ndim_domain > 1: - shifts[:,:2] = np.flip(shifts[:,:2], axis=1) + shifts[:, :2] = np.flip(shifts[:, :2], axis=1) shifts = np.repeat(shifts[..., np.newaxis], - eval_points.shape[1], axis=2) + eval_points.shape[1], axis=2) eval_points_shifted = eval_points_repeat + shifts - - grid = True if self.ndim_domain > 1 else False - data_matrix = self.evaluate(eval_points_shifted, extrapolation=extrapolation, aligned_evaluation=False, grid=True) - return self.copy(data_matrix=data_matrix, sample_points=eval_points, domain_range=domain) @@ -928,7 +912,7 @@ def compose(self, fd, *, eval_points=None): if eval_points is None: try: eval_points = fd.sample_points[0] - except: + except AttributeError: eval_points = np.linspace(*fd.domain_range[0], 201) eval_points_transformation = fd(eval_points, keepdims=False) @@ -942,10 +926,9 @@ def compose(self, fd, *, eval_points=None): lengths = [len(ax) for ax in eval_points] - eval_points_transformation = np.empty((self.nsamples, - np.prod(lengths), - self.ndim_domain)) - + eval_points_transformation = np.empty((self.nsamples, + np.prod(lengths), + self.ndim_domain)) for i in range(self.nsamples): eval_points_transformation[i] = np.array( @@ -953,18 +936,15 @@ def compose(self, fd, *, eval_points=None): ).T data_flatten = self(eval_points_transformation, - aligned_evaluation=False) + aligned_evaluation=False) data_matrix = data_flatten.reshape((self.nsamples, *lengths, self.ndim_image)) - return self.copy(data_matrix=data_matrix, sample_points=eval_points, domain_range=fd.domain_range) - - def __str__(self): """Return str(self).""" return ('Data set: ' + str(self.data_matrix) @@ -1003,11 +983,15 @@ def __getitem__(self, key): else: return self.copy(data_matrix=self.data_matrix[key]) + ##################################################################### + # Numpy methods + ##################################################################### + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): for i in inputs: if isinstance(i, FDataGrid) and not np.all(i.sample_points == - self.sample_points): + self.sample_points): return NotImplemented new_inputs = [i.data_matrix if isinstance(i, FDataGrid) @@ -1035,3 +1019,41 @@ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): results = [self.copy(data_matrix=r) for r in results] return results[0] if len(results) == 1 else results + + ##################################################################### + # Pandas ExtensionArray methods + ##################################################################### + @property + def dtype(self): + """The dtype for this extension array, FDataGridDType""" + return FDataGridDType + + @property + def nbytes(self) -> int: + """ + The number of bytes needed to store this object in memory. + """ + return self.data_matrix.nbytes() + sum( + p.nbytes() for p in self.sample_points) + + +class FDataGridDType(pandas.api.extensions.ExtensionDtype): + """ + DType corresponding to FDataGrid in Pandas + """ + name = 'functional data (grid)' + kind = 'O' + type = FDataGrid + na_value = None + + @classmethod + def construct_from_string(cls, string): + if string == cls.name: + return cls() + else: + raise TypeError("Cannot construct a '{}' from " + "'{}'".format(cls, string)) + + @classmethod + def construct_array_type(cls): + return FDataGrid From 97581e04ad89c933040089f749735d72c745e10b Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Thu, 6 Jun 2019 17:46:32 +0200 Subject: [PATCH 056/222] Changes for pull request --- skfda/representation/basis.py | 30 +++++++++++++++--------------- tests/test_basis.py | 6 +++--- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index c29305e9e..77401d1d3 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -325,7 +325,7 @@ def _list_to_R(self, knots): def _to_R(self): raise NotImplementedError - def inner_matrix(self, other=None): + def _inner_matrix(self, other=None): r"""Return the Inner Product Matrix of a pair of basis. @@ -342,31 +342,31 @@ def inner_matrix(self, other=None): Args: other (:class:`Basis`): Basis to compute the inner product matrix. If not basis is given, it computes the matrix with - itself returning the Gramian Matrix + itself returning the Gram Matrix Returns: numpy.array: Inner Product Matrix of two basis """ if other is None or self == other: - return self.gramian_matrix() + return self.gram_matrix() first = self.to_basis() second = other.to_basis() - gramian = np.zeros((self.nbasis, other.nbasis)) + inner = np.zeros((self.nbasis, other.nbasis)) for i in range(self.nbasis): for j in range(other.nbasis): - gramian[i, j] = first[i].inner_product(second[j], None, None) + inner[i, j] = first[i].inner_product(second[j], None, None) - return gramian + return inner - def gramian_matrix(self): + def gram_matrix(self): - r"""Return the Gramian Matrix of a basis + r"""Return the Gram Matrix of a basis - The Gramian Matrix is defined as + The Gram Matrix is defined as .. math:: G_{ij} = \langle\phi_i, \phi_j\rangle @@ -375,19 +375,19 @@ def gramian_matrix(self): positive-semidefinite. Returns: - numpy.array: Gramian Matrix of the basis. + numpy.array: Gram Matrix of the basis. """ fbasis = self.to_basis() - gramian = np.zeros((self.nbasis, self.nbasis)) + gram = np.zeros((self.nbasis, self.nbasis)) for i in range(fbasis.nbasis): for j in range(i, fbasis.nbasis): - gramian[i, j] = fbasis[i].inner_product(fbasis[j], None, None) - gramian[j, i] = gramian[i, j] + gram[i, j] = fbasis[i].inner_product(fbasis[j], None, None) + gram[j, i] = gram[i, j] - return gramian + return gram def inner_product(self, other): return np.transpose(other.inner_product(self.to_basis())) @@ -2240,7 +2240,7 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, other = other.times(weights) if self.nsamples * other.nsamples > self.nbasis * other.nbasis: - return self.coefficients @ self.basis.inner_matrix(other.basis) @ other.coefficients.T + return self.coefficients @ self.basis._inner_matrix(other.basis) @ other.coefficients.T else: return self._inner_product_integrate(other, lfd_self, lfd_other) diff --git a/tests/test_basis.py b/tests/test_basis.py index 4a3c27d54..63508c20c 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -128,13 +128,13 @@ def test_basis_bspline_product(self): self.assertEqual(bspline.basis_of_product(bspline2), prod) def test_basis_inner_matrix(self): - np.testing.assert_array_almost_equal(Monomial(nbasis=3).inner_matrix(), + np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(), [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) - np.testing.assert_array_almost_equal(Monomial(nbasis=3).inner_matrix(Monomial(nbasis=3)), + np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(Monomial(nbasis=3)), [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) - np.testing.assert_array_almost_equal(Monomial(nbasis=3).inner_matrix(Monomial(nbasis=4)), + np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(Monomial(nbasis=4)), [[1, 1/2, 1/3, 1/4], [1/2, 1/3, 1/4, 1/5], [1/3, 1/4, 1/5, 1/6]]) # TODO testing with other basis From 157ed30875f72306b26b263c04cfd7a889601f10 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Thu, 6 Jun 2019 18:11:39 +0200 Subject: [PATCH 057/222] Some test fixes --- tests/test_basis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index 63508c20c..917f38d49 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -139,8 +139,8 @@ def test_basis_inner_matrix(self): # TODO testing with other basis - def test_basis_gramian_matrix(self): - np.testing.assert_array_almost_equal(Monomial(nbasis=3).gramian_matrix(), + def test_basis_gram_matrix(self): + np.testing.assert_array_almost_equal(Monomial(nbasis=3).gram_matrix(), [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) # TODO testing with other basis From 993628562cec88ef7109e678379ad18298569d50 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Thu, 6 Jun 2019 19:03:50 +0200 Subject: [PATCH 058/222] Some more tests --- tests/test_basis.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/tests/test_basis.py b/tests/test_basis.py index 917f38d49..fd383316c 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -142,8 +142,15 @@ def test_basis_inner_matrix(self): def test_basis_gram_matrix(self): np.testing.assert_array_almost_equal(Monomial(nbasis=3).gram_matrix(), [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) - - # TODO testing with other basis + np.testing.assert_almost_equal(Fourier(nbasis=3).gram_matrix(), + np.identity(3)) + np.testing.assert_almost_equal(BSpline(nbasis=6).gram_matrix().round(4), + np.array([[4.760e-02, 2.920e-02, 6.200e-03, 4.000e-04, 0.000e+00, 0.000e+00], + [2.920e-02, 7.380e-02, 5.210e-02, 1.150e-02, 1.000e-04, 0.000e+00], + [6.200e-03, 5.210e-02, 1.090e-01, 7.100e-02, 1.150e-02, 4.000e-04], + [4.000e-04, 1.150e-02, 7.100e-02, 1.090e-01, 5.210e-02, 6.200e-03], + [0.000e+00, 1.000e-04, 1.150e-02, 5.210e-02, 7.380e-02, 2.920e-02], + [0.000e+00, 0.000e+00, 4.000e-04, 6.200e-03, 2.920e-02, 4.760e-02]])) def test_basis_basis_inprod(self): monomial = Monomial(nbasis=4) From 318f86fb00df33b1bc6b48b620a6c2b477f01b31 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Thu, 6 Jun 2019 19:19:08 +0200 Subject: [PATCH 059/222] Some minor changes --- skfda/ml/regression/scalar.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skfda/ml/regression/scalar.py b/skfda/ml/regression/scalar.py index 79764119b..436b6aa6a 100644 --- a/skfda/ml/regression/scalar.py +++ b/skfda/ml/regression/scalar.py @@ -1,7 +1,7 @@ from sklearn.metrics import mean_squared_error from sklearn.base import BaseEstimator, RegressorMixin - from skfda.representation.basis import * + import numpy as np @@ -47,7 +47,7 @@ def fit(self, X, y=None): self.beta = beta - def predict(self, X) + def predict(self, X): return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in range(len(self.beta))) for j in range(X[0].nsamples)] From 80a4b208f717ec11ef41b41a45bb6754dcdd99e1 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Thu, 6 Jun 2019 22:36:06 +0200 Subject: [PATCH 060/222] Refactor due to sklearn package --- skfda/ml/regression/__init__.py | 1 + .../regression/{scalar.py => linear_model.py} | 21 +++++++++---------- tests/test_regression.py | 12 +++++------ 3 files changed, 17 insertions(+), 17 deletions(-) rename skfda/ml/regression/{scalar.py => linear_model.py} (85%) diff --git a/skfda/ml/regression/__init__.py b/skfda/ml/regression/__init__.py index e69de29bb..fbcd794cc 100644 --- a/skfda/ml/regression/__init__.py +++ b/skfda/ml/regression/__init__.py @@ -0,0 +1 @@ +from .linear_model import LinearScalarRegression \ No newline at end of file diff --git a/skfda/ml/regression/scalar.py b/skfda/ml/regression/linear_model.py similarity index 85% rename from skfda/ml/regression/scalar.py rename to skfda/ml/regression/linear_model.py index 436b6aa6a..ecc867e4e 100644 --- a/skfda/ml/regression/scalar.py +++ b/skfda/ml/regression/linear_model.py @@ -5,7 +5,8 @@ import numpy as np -class ScalarRegression(BaseEstimator, RegressorMixin): +class LinearScalarRegression(BaseEstimator, RegressorMixin): + def __init__(self, beta, weights=None): self.beta = beta self.weights = weights @@ -28,13 +29,11 @@ def fit(self, X, y=None): if any(w != 1 for w in wt): rtwt = np.sqrt(wt) - Zmatwt = Zmat * rtwt - ymatwt = y * rtwt - Cmat = np.transpose(Zmatwt @ Zmatwt) - Dmat = np.transpose(Zmatwt) @ ymatwt - else: - Cmat = np.transpose(Zmat) @ Zmat - Dmat = np.transpose(Zmat) @ y + Zmat = Zmat * rtwt + y = y * rtwt + + Cmat = np.transpose(Zmat) @ Zmat + Dmat = np.transpose(Zmat) @ y Cmatinv = np.linalg.inv(Cmat) betacoef = Cmatinv @ Dmat @@ -56,16 +55,16 @@ def mean_squared_error(self, y_actual, y_predicted): def _argcheck(self, y, x): """Do some checks to types and shapes""" - if all(not isinstance(i, FDataBasis) for i in x): + if all(not isinstance(i, FData) for i in x): raise ValueError("All the dependent variable are scalar.") - if any(isinstance(i, FDataBasis) for i in y): + if any(isinstance(i, FData) for i in y): raise ValueError( "Some of the independent variables are not scalar") ylen = len(y) xlen = len(x) blen = len(self.beta) - domain_range = ([i for i in x if isinstance(i, FDataBasis)][0] + domain_range = ([i for i in x if isinstance(i, FData)][0] .domain_range) if blen != xlen: diff --git a/tests/test_regression.py b/tests/test_regression.py index b72f66b30..dd451faf3 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,6 +1,6 @@ import unittest from skfda.representation.basis import Monomial, Fourier, FDataBasis -from skfda.ml.regression.scalar import ScalarRegression +from skfda.ml.regression import LinearScalarRegression import numpy as np class TestRegression(unittest.TestCase): @@ -13,11 +13,11 @@ def test_scalar_regression(self): x_Basis = Monomial(nbasis=7) x_fd = FDataBasis(x_Basis, np.identity(7)) - scalar_test = ScalarRegression([beta_fd]) + scalar_test = LinearScalarRegression([beta_fd]) y = scalar_test.predict([x_fd]) - scalar = ScalarRegression([beta_Basis]) - scalar.fit(y, [x_fd]) + scalar = LinearScalarRegression([beta_Basis]) + scalar.fit([x_fd], y) np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, beta_fd.coefficients) @@ -31,8 +31,8 @@ def test_scalar_regression(self): 0.10549625973303875, 0.11384314859153018] - scalar = ScalarRegression([beta_Basis]) - scalar.fit(y, [x_fd]) + scalar = LinearScalarRegression([beta_Basis]) + scalar.fit([x_fd], y) np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, beta_fd.coefficients) From 1b0fd0fbc7db7a0ce437b7ac6a0cf9c14ba66ec2 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 7 Jun 2019 17:18:57 +0200 Subject: [PATCH 061/222] Change weights names --- .../smoothing/kernel_smoothers.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 42c6b97c6..3721c33dd 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -22,7 +22,7 @@ @parameter_aliases(smoothing_parameter=['h', 'bandwidth']) def nadaraya_watson(argvals, *, smoothing_parameter=None, - kernel=kernels.normal, w=None, cv=False): + kernel=kernels.normal, weights=None, cv=False): r"""Nadaraya-Watson smoothing method. Provides an smoothing matrix :math:`\hat{H}` for the discretisation @@ -44,7 +44,8 @@ def nadaraya_watson(argvals, *, smoothing_parameter=None, smoothing_parameter (float, optional): Window width of the kernel. kernel (function, optional): kernel function. By default a normal kernel. - w (ndarray, optional): Case weights matrix. + weights (ndarray, optional): Case weights matrix (in order to modify + the importance of each point in each observation). cv (bool, optional): Flag for cross-validation methods. Defaults to False. h (float, optional): same as smoothing_parameter. @@ -76,8 +77,8 @@ def nadaraya_watson(argvals, *, smoothing_parameter=None, np.fill_diagonal(delta_x, math.inf) delta_x = delta_x / smoothing_parameter k = kernel(delta_x) - if w is not None: - k = k * w + if weights is not None: + k = k * weights rs = np.sum(k, 1) rs[rs == 0] = 1 return (k.T / rs).T @@ -85,7 +86,7 @@ def nadaraya_watson(argvals, *, smoothing_parameter=None, @parameter_aliases(smoothing_parameter=['h', 'bandwidth']) def local_linear_regression(argvals, smoothing_parameter, *, - kernel=kernels.normal, w=None, + kernel=kernels.normal, weights=None, cv=False): r"""Local linear regression smoothing method. @@ -113,7 +114,8 @@ def local_linear_regression(argvals, smoothing_parameter, *, smoothing_parameter (float, optional): Window width of the kernel. kernel (function, optional): kernel function. By default a normal kernel. - w (ndarray, optional): Case weights matrix. + weights (ndarray, optional): Case weights matrix (in order to modify + the importance of each point in each observation). cv (bool, optional): Flag for cross-validation methods. Defaults to False. h (float, optional): same as smoothing_parameter. @@ -147,15 +149,15 @@ def local_linear_regression(argvals, smoothing_parameter, *, b = (k * (s2 - delta_x * s1)).T # b_i(x_j) if cv: np.fill_diagonal(b, 0) - if w is not None: - b = b * w + if weights is not None: + b = b * weights rs = np.sum(b, 1) # sum_{k=1}^{n}b_k(x_j) return (b.T / rs).T # \\hat{H} @parameter_aliases(smoothing_parameter=['k', 'n_neighbors']) def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, - w=None, cv=False): + weights=None, cv=False): """K-nearest neighbour kernel smoother. Provides an smoothing matrix S for the discretisation points in argvals by @@ -172,7 +174,8 @@ def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, default it takes the 5% closest points. kernel (function, optional): kernel function. By default a uniform kernel to perform a 'usual' k nearest neighbours estimation. - w (ndarray, optional): Case weights matrix. + weights (ndarray, optional): Case weights matrix (in order to modify + the importance of each point in each observation). cv (bool, optional): Flag for cross-validation methods. Defaults to False. k (float, optional): same as smoothing_parameter. @@ -226,8 +229,8 @@ def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, # to the knn are below 1 and the rest above 1 so the kernel returns values # distinct to 0 only for the knn. - if w is not None: - rr = (rr.T * w).T + if weights is not None: + rr = (rr.T * weights).T # normalise every row rs = np.sum(rr, 1) From 0b509b31e60d875869537efaf11ea8c871afe09e Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Mon, 10 Jun 2019 12:29:34 +0200 Subject: [PATCH 062/222] Some improvement issues --- skfda/ml/regression/linear_model.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index ecc867e4e..7c0a2746c 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -32,8 +32,8 @@ def fit(self, X, y=None): Zmat = Zmat * rtwt y = y * rtwt - Cmat = np.transpose(Zmat) @ Zmat - Dmat = np.transpose(Zmat) @ y + Cmat = Zmat.T @ Zmat + Dmat = Zmat.T @ y Cmatinv = np.linalg.inv(Cmat) betacoef = Cmatinv @ Dmat @@ -42,7 +42,7 @@ def fit(self, X, y=None): for j in range(0, nbeta): mj1 = mj2 mj2 = mj2 + beta[j].nbasis - beta[j] = FDataBasis(beta[j], np.transpose(betacoef[mj1:mj2])) + beta[j] = FDataBasis(beta[j], betacoef[mj1:mj2].T) self.beta = beta @@ -50,8 +50,8 @@ def predict(self, X): return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in range(len(self.beta))) for j in range(X[0].nsamples)] - def mean_squared_error(self, y_actual, y_predicted): - return np.sqrt(mean_squared_error(y_actual, y_predicted)) + def _mean_squared_error(self, y_actual, y_predicted): + return mean_squared_error(y_actual, y_predicted) def _argcheck(self, y, x): """Do some checks to types and shapes""" @@ -96,6 +96,6 @@ def _argcheck(self, y, x): return y, x, self.beta, self.weights def score(self, X, y, sample_weight=None): - pass + return self._mean_squared_error(y, self.predict(X)) From 678d457435b3990e5998c9134d5b8c1df6b3ebef Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Mon, 10 Jun 2019 12:36:37 +0200 Subject: [PATCH 063/222] Tests and names fixed --- skfda/ml/regression/linear_model.py | 22 ++++++++++------------ tests/test_regression.py | 24 +++++++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 7c0a2746c..71ec61de4 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -13,7 +13,7 @@ def __init__(self, beta, weights=None): def fit(self, X, y=None): - y, X, beta, wt = self._argcheck(y, X) + y, X, beta, weights = self._argcheck(y, X) nbeta = len(beta) nsamples = X[0].nsamples @@ -22,13 +22,12 @@ def fit(self, X, y=None): for j in range(nbeta): xcoef = X[j].coefficients - xbasis = X[j].basis - Jpsithetaj = xbasis.inner_product(beta[j]) - Zmat = xcoef @ Jpsithetaj if j == 0 else np.concatenate( - (Zmat, xcoef @ Jpsithetaj), axis=1) + inner_x_beta = X[j].basis.inner_product(beta[j]) + Zmat = xcoef @ inner_x_beta if j == 0 else np.concatenate( + (Zmat, xcoef @ inner_x_beta), axis=1) - if any(w != 1 for w in wt): - rtwt = np.sqrt(wt) + if any(w != 1 for w in weights): + rtwt = np.sqrt(weights) Zmat = Zmat * rtwt y = y * rtwt @@ -36,13 +35,12 @@ def fit(self, X, y=None): Dmat = Zmat.T @ y Cmatinv = np.linalg.inv(Cmat) - betacoef = Cmatinv @ Dmat + betacoefs = Cmatinv @ Dmat - mj2 = 0 + idx = 0 for j in range(0, nbeta): - mj1 = mj2 - mj2 = mj2 + beta[j].nbasis - beta[j] = FDataBasis(beta[j], betacoef[mj1:mj2].T) + beta[j] = FDataBasis(beta[j], betacoefs[idx:beta[j].nbasis].T) + idx = idx + beta[j].nbasis self.beta = beta diff --git a/tests/test_regression.py b/tests/test_regression.py index dd451faf3..6886335f7 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -6,23 +6,28 @@ class TestRegression(unittest.TestCase): """Test regression""" - def test_scalar_regression(self): - beta_Basis = Fourier(nbasis=5) - beta_fd = FDataBasis(beta_Basis, [1, 2, 3, 4, 5]) + def test_linear_scalar_regression_auto(self): + beta_basis = Fourier(nbasis=5) + beta_fd = FDataBasis(beta_basis, [1, 2, 3, 4, 5]) - x_Basis = Monomial(nbasis=7) - x_fd = FDataBasis(x_Basis, np.identity(7)) + x_basis = Monomial(nbasis=7) + x_fd = FDataBasis(x_basis, np.identity(7)) scalar_test = LinearScalarRegression([beta_fd]) y = scalar_test.predict([x_fd]) - scalar = LinearScalarRegression([beta_Basis]) + scalar = LinearScalarRegression([beta_basis]) scalar.fit([x_fd], y) np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, beta_fd.coefficients) - beta_Basis = Fourier(nbasis=5) - beta_fd = FDataBasis(beta_Basis, [1, 1, 1, 1, 1]) + def test_linear_scalar_regression(self): + + x_basis = Monomial(nbasis=7) + x_fd = FDataBasis(x_basis, np.identity(7)) + + beta_basis = Fourier(nbasis=5) + beta_fd = FDataBasis(beta_basis, [1, 1, 1, 1, 1]) y = [1.0000684777229512, 0.1623672257830915, 0.08521053851548224, @@ -31,11 +36,12 @@ def test_scalar_regression(self): 0.10549625973303875, 0.11384314859153018] - scalar = LinearScalarRegression([beta_Basis]) + scalar = LinearScalarRegression([beta_basis]) scalar.fit([x_fd], y) np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, beta_fd.coefficients) + if __name__ == '__main__': print() unittest.main() From 68a5678e62c0b419f50cc92143972b31c4950b8a Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 10 Jun 2019 18:30:59 +0200 Subject: [PATCH 064/222] Smoothers as Scikit-learn transformers --- skfda/_utils.py | 68 ++++++++++++---- .../smoothing/kernel_smoothers.py | 79 +++++++++++++++++++ tests/test_smoothing.py | 15 ++++ 3 files changed, 147 insertions(+), 15 deletions(-) create mode 100644 tests/test_smoothing.py diff --git a/skfda/_utils.py b/skfda/_utils.py index 644c7dfb7..4ad5b428e 100644 --- a/skfda/_utils.py +++ b/skfda/_utils.py @@ -2,6 +2,7 @@ import numpy as np import functools +import types def _list_of_arrays(original_array): @@ -71,24 +72,61 @@ def _coordinate_list(axes): def parameter_aliases(**alias_assignments): """Allows using aliases for parameters""" def decorator(f): - @functools.wraps(f) - def aliasing_function(*args, **kwargs): + + if isinstance(f, (types.FunctionType, types.LambdaType)): + # f is a function + @functools.wraps(f) + def aliasing_function(*args, **kwargs): + nonlocal alias_assignments + for parameter_name, aliases in alias_assignments.items(): + aliases = tuple(aliases) + aliases_used = [a for a in kwargs + if a in aliases + (parameter_name,)] + if len(aliases_used) > 1: + raise ValueError( + f"Several arguments with the same meaning used: " + + str(aliases_used)) + + elif len(aliases_used) == 1: + arg = kwargs.pop(aliases_used[0]) + kwargs[parameter_name] = arg + + return f(*args, **kwargs) + return aliasing_function + + else: + # f is a class (an estimator) + + class cls(f): + pass + nonlocal alias_assignments - for parameter_name, aliases in alias_assignments.items(): - aliases = tuple(aliases) - aliases_used = [a for a in kwargs - if a in aliases + (parameter_name,)] - if len(aliases_used) > 1: - raise ValueError( - f"Several arguments with the same meaning used: " + - str(aliases_used)) + init = cls.__init__ + cls.__init__ = parameter_aliases(**alias_assignments)(init) + + set_params = cls.set_params + cls.set_params = parameter_aliases(**alias_assignments)(set_params) + + for key, value in alias_assignments.items(): + def getter(self): + return getattr(self, key) - elif len(aliases_used) == 1: - arg = kwargs.pop(aliases_used[0]) - kwargs[parameter_name] = arg + def setter(self, new_value): + return setattr(self, key, new_value) - return f(*args, **kwargs) + for alias in value: + setattr(cls, alias, property(getter, setter)) - return aliasing_function + return cls return decorator + + +def _check_estimator(estimator): + from sklearn.utils.estimator_checks import ( + check_get_params_invariance, check_set_params) + + name = estimator.__name__ + instance = estimator() + check_get_params_invariance(name, instance) + check_set_params(name, instance) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 3721c33dd..2bce0d0fd 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -15,6 +15,10 @@ from ...misc import kernels from ..._utils import parameter_aliases +from sklearn.base import BaseEstimator, TransformerMixin +from skfda.representation.grid import FDataGrid +import abc +from abc import abstractclassmethod __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" @@ -235,3 +239,78 @@ def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, # normalise every row rs = np.sum(rr, 1) return (rr.T / rs).T + + +def _check_r_to_r(f): + if f.ndim_domain != 1 or f.ndim_codomain != 1: + raise NotImplementedError("Only accepts functions from R to R") + + +class _LinearKernelSmoother(abc.ABC, BaseEstimator, TransformerMixin): + + def __init__(self, *, smoothing_parameter=None, + kernel=kernels.normal, weights=None): + self.smoothing_parameter = smoothing_parameter + self.kernel = kernel + self.weights = weights + + @abc.abstractmethod + def _hat_matrix_function(self): + pass + + def _more_tags(self): + return { + 'X_types': [] + } + + def fit(self, X: FDataGrid, y=None): + + _check_r_to_r(X) + + self.input_points_ = X.sample_points[0] + + self.hat_matrix_ = self.hat_matrix_function() + + return self + + def transform(self, X: FDataGrid, y=None): + + assert self.input_points_ == X.sample_points[0] + + return X.copy(data_matrix=self.hat_matrix_ @ X.data_matrix) + + +@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) +class NadarayaWatsonSmoother(_LinearKernelSmoother): + + def _hat_matrix_function(self): + return nadaraya_watson( + self.input_points_, + smoothing_parameter=self.smoothing_parameter, + weights=self.weights) + + return self + + +@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) +class LocalLinearRegressionSmoother(_LinearKernelSmoother): + + def _hat_matrix_function(self): + return local_linear_regression( + self.input_points_, + smoothing_parameter=self.smoothing_parameter, + weights=self.weights) + + return self + + +@parameter_aliases(smoothing_parameter=['k', 'n_neighbors']) +class KNeighborsSmoother(_LinearKernelSmoother): + + def _hat_matrix_function(self): + return knn( + self.input_points_, + smoothing_parameter=self.smoothing_parameter, + weights=self.weights) + + return self diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py new file mode 100644 index 000000000..ffe088e62 --- /dev/null +++ b/tests/test_smoothing.py @@ -0,0 +1,15 @@ +import unittest +from skfda._utils import _check_estimator +import skfda.preprocessing.smoothing.kernel_smoothers as kernel_smoothers + + +class TestSklearnEstimators(unittest.TestCase): + + def test_nadaraya_watson(self): + _check_estimator(kernel_smoothers.NadarayaWatsonSmoother) + + def test_local_linear_regression(self): + _check_estimator(kernel_smoothers.LocalLinearRegressionSmoother) + + def test_knn(self): + _check_estimator(kernel_smoothers.KNeighborsSmoother) From 84a1fcf39144dfa843c6276265ce736ea65458f2 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 10 Jun 2019 20:31:24 +0200 Subject: [PATCH 065/222] Add linear smoother scorers --- .../smoothing/kernel_smoothers.py | 4 +- skfda/preprocessing/smoothing/validation.py | 160 ++++++++++-------- 2 files changed, 91 insertions(+), 73 deletions(-) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 2bce0d0fd..56ec17387 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -269,13 +269,13 @@ def fit(self, X: FDataGrid, y=None): self.input_points_ = X.sample_points[0] - self.hat_matrix_ = self.hat_matrix_function() + self.hat_matrix_ = self._hat_matrix_function() return self def transform(self, X: FDataGrid, y=None): - assert self.input_points_ == X.sample_points[0] + assert all(self.input_points_ == X.sample_points[0]) return X.copy(data_matrix=self.hat_matrix_ @ X.data_matrix) diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 2fdfce698..8e01dadf4 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -8,12 +8,12 @@ __email__ = "miguel.carbajo@estudiante.uam.es" -def cv(fdatagrid, s_matrix): - r"""Cross validation scoring method. +class LinearSmootherLeaveOneOutScorer(): + r"""Leave-one-out cross validation scoring method for linear smoothers. It calculates the cross validation score for every sample in a FDataGrid - object given a smoothing matrix :math:`\hat{H}^\nu` calculated with a - parameter :math:`\nu`: + object given a linear smoother with a smoothing matrix :math:`\hat{H}^\nu` + calculated with a parameter :math:`\nu`: .. math:: CV(\nu)=\frac{1}{n} \sum_i \left(y_i - \hat{y}_i^{\nu( @@ -30,20 +30,26 @@ def cv(fdatagrid, s_matrix): \hat{H}_{ii}^\nu}\right)^2 Args: - fdatagrid (FDataGrid): Object over which the CV score is calculated. - s_matrix (numpy.darray): Smoothig matrix. + estimator (Estimator): Linear smoothing estimator. + X (FDataGrid): Functional data to smooth. + y (FDataGrid): Functional data target. Should be the same as X. Returns: float: Cross validation score. """ - y = fdatagrid.data_matrix[..., 0] - y_est = np.dot(s_matrix, y.T).T - return np.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) + + def __call__(self, estimator, X, y): + y_est = estimator.transform(X) + + hat_matrix = estimator._hat_matrix_function() + + return np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) + / (1 - hat_matrix.diagonal())) ** 2) -def gcv(fdatagrid, s_matrix, penalisation_function=None): - r"""General cross validation scoring method. +class LinearSmootherGeneralizedCVScorer(): + r"""Generalized cross validation scoring method for linear smoothers. It calculates the general cross validation score for every sample in a FDataGrid object given a smoothing matrix :math:`\hat{H}^\nu` @@ -54,37 +60,45 @@ def gcv(fdatagrid, s_matrix, penalisation_function=None): y}_i^\nu\right)^2 Where :math:`\hat{y}_i^{\nu}` is the adjusted :math:`y_i` and - :math:`\Xi` is a penalisation function. By default the penalisation + :math:`\Xi` is a penalization function. By default the penalization function is: .. math:: \Xi(\nu,n) = \left( 1 - \frac{tr(\hat{H}^\nu)}{n} \right)^{-2} - But others such as the Akaike's information criterion can be considered. + but others such as the Akaike's information criterion can be considered. Args: - fdatagrid (FDataGrid): Object over which the CV score is calculated. - s_matrix (numpy.darray): Smoothig matrix. - penalisation_function (Function): Function taking a smoothing matrix - and returing a penalisation score. If None the general cross - validation penalisation is applied. Defaults to None. + estimator (Estimator): Linear smoothing estimator. + X (FDataGrid): Functional data to smooth. + y (FDataGrid): Functional data target. Should be the same as X. Returns: float: Cross validation score. """ - y = fdatagrid.data_matrix[..., 0] - y_est = np.dot(s_matrix, y.T).T - if penalisation_function is not None: - return (np.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) - * penalisation_function(s_matrix)) - return (np.mean(((y - y_est) / (1 - s_matrix.diagonal())) ** 2) - * (1 - s_matrix.diagonal().mean()) ** -2) - - -def minimise(fdatagrid, parameters, - smoothing_method=kernel_smoothers.nadaraya_watson, cv_method=gcv, - penalisation_function=None, **kwargs): + def __init__(self, penalization_function=None): + self.penalization_function = penalization_function + + def __call__(self, estimator, X, y): + y_est = estimator.transform(X) + + hat_matrix = estimator._hat_matrix_function() + + if self.penalization_function is None: + def penalization_function(hat_matrix): + return (1 - hat_matrix.diagonal().mean()) ** -2 + else: + penalization_function = self.penalization_function + + return (np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) + / (1 - hat_matrix.diagonal())) ** 2) + * penalization_function(hat_matrix)) + + +def minimize(fdatagrid, parameters, + smoothing_method=None, + cv_method=None): """Chooses the best smoothness parameter and performs smoothing. Performs the smoothing of a FDataGrid object choosing the best @@ -99,8 +113,8 @@ def minimise(fdatagrid, parameters, cv_method (Function): Function that takes a matrix, a smoothing matrix, and optionally a weights matrix and calculates a cross validation score. - penalisation_function(Fuction): if gcv is selected as cv_method a - penalisation function can be specified through this parameter. + penalization_function(Fuction): if gcv is selected as cv_method a + penalization function can be specified through this parameter. Returns: dict: A dictionary containing the following: @@ -128,7 +142,8 @@ def minimise(fdatagrid, parameters, >>> import skfda >>> x = np.linspace(-2, 2, 5) >>> fd = skfda.FDataGrid(x ** 2, x) - >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn) + >>> res = minimize(fd, [2,3], + ... smoothing_method=kernel_smoothers.KNeighborsSmoother()) >>> np.array(res['scores']).round(2) array([ 11.67, 12.37]) >>> round(res['best_score'], 2) @@ -153,26 +168,31 @@ def minimise(fdatagrid, parameters, ...) Other validation methods can be used such as cross-validation or - general cross validation using other penalisation functions. + general cross validation using other penalization functions. - >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, - ... cv_method=cv) + >>> res = minimize(fd, [2,3], + ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), + ... cv_method=LinearSmootherLeaveOneOutScorer()) >>> np.array(res['scores']).round(2) array([ 4.2, 5.5]) - >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, - ... penalisation_function=aic) + >>> res = minimize(fd, [2,3], + ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), + ... cv_method=LinearSmootherGeneralizedCVScorer(aic)) >>> np.array(res['scores']).round(2) array([ 9.35, 10.71]) - >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, - ... penalisation_function=fpe) + >>> res = minimize(fd, [2,3], + ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), + ... cv_method=LinearSmootherGeneralizedCVScorer(fpe)) >>> np.array(res['scores']).round(2) array([ 9.8, 11. ]) - >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, - ... penalisation_function=shibata) + >>> res = minimize(fd, [2,3], + ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), + ... cv_method=LinearSmootherGeneralizedCVScorer(shibata)) >>> np.array(res['scores']).round(2) array([ 7.56, 9.17]) - >>> res = minimise(fd, [2,3], smoothing_method=kernel_smoothers.knn, - ... penalisation_function=rice) + >>> res = minimize(fd, [2,3], + ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), + ... cv_method=LinearSmootherGeneralizedCVScorer(rice)) >>> np.array(res['scores']).round(2) array([ 21. , 16.5]) @@ -185,34 +205,32 @@ def minimise(fdatagrid, parameters, raise NotImplementedError("This method only works when the dimension " "of the image of the FDatagrid object is " "one.") + + if smoothing_method is None: + smoothing_method = kernel_smoothers.NadarayaWatsonSmoother() + + if cv_method is None: + cv_method = LinearSmootherGeneralizedCVScorer() + # Reduce one dimension the sample points. - sample_points = fdatagrid.sample_points[0] scores = [] + # Calculates the scores for each parameter. - if penalisation_function is not None: - for h in parameters: - s = smoothing_method(sample_points, smoothing_parameter=h, - **kwargs) - scores.append( - cv_method(fdatagrid, s, - penalisation_function=penalisation_function)) - else: - for h in parameters: - s = smoothing_method(sample_points, smoothing_parameter=h, - **kwargs) - scores.append( - cv_method(fdatagrid, s)) + for h in parameters: + smoothing_method.smoothing_parameter = h + smoothing_method.fit(fdatagrid) + scores.append(cv_method(smoothing_method, fdatagrid, fdatagrid)) + # gets the best parameter. h = parameters[int(np.argmin(scores))] - s = smoothing_method(sample_points, smoothing_parameter=h, **kwargs) - fdatagrid_adjusted = fdatagrid.copy( - data_matrix=np.dot(fdatagrid.data_matrix[..., 0], s.T)) + smoothing_method.smoothing_parameter = h + smoothing_method.fit(fdatagrid) return {'scores': scores, 'best_score': np.min(scores), 'best_parameter': h, - 'hat_matrix': s, - 'fdatagrid': fdatagrid_adjusted, + 'hat_matrix': smoothing_method._hat_matrix_function(), + 'fdatagrid': smoothing_method.transform(fdatagrid) } @@ -223,11 +241,11 @@ def aic(s_matrix): \Xi(\nu,n) = \exp\left(2 * \frac{tr(\hat{H}^\nu)}{n}\right) Args: - s_matrix (numpy.darray): Smoothing matrix whose penalisation + s_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: - float: Penalisation given by the Akaike's information criterion. + float: penalization given by the Akaike's information criterion. """ return np.exp(2 * s_matrix.diagonal().mean()) @@ -241,11 +259,11 @@ def fpe(s_matrix): \frac{tr(\hat{H}^\nu)}{n}} Args: - s_matrix (numpy.darray): Smoothing matrix whose penalisation + s_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: - float: Penalisation given by the finite prediction error. + float: penalization given by the finite prediction error. """ return (1 + s_matrix.diagonal().mean()) / (1 - s_matrix.diagonal().mean()) @@ -258,11 +276,11 @@ def shibata(s_matrix): \Xi(\nu,n) = 1 + 2 * \frac{tr(\hat{H}^\nu)}{n} Args: - s_matrix (numpy.darray): Smoothing matrix whose penalisation + s_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: - float: Penalisation given by the Shibata's model selector. + float: penalization given by the Shibata's model selector. """ return 1 + 2 * s_matrix.diagonal().mean() @@ -275,11 +293,11 @@ def rice(s_matrix): \Xi(\nu,n) = \left(1 - 2 * \frac{tr(\hat{H}^\nu)}{n}\right)^{-1} Args: - s_matrix (numpy.darray): Smoothing matrix whose penalisation + s_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: - float: Penalisation given by the Rice's bandwidth selector. + float: penalization given by the Rice's bandwidth selector. """ return (1 - 2 * s_matrix.diagonal().mean()) ** -1 From 7d9171ed161f0d1ab6f1d356c3a8b1ece8c9278e Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 11 Jun 2019 00:00:47 +0200 Subject: [PATCH 066/222] Doctests for the transformers --- skfda/_utils.py | 12 +- .../smoothing/kernel_smoothers.py | 151 +++++++++++++++++- skfda/preprocessing/smoothing/validation.py | 4 +- 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/skfda/_utils.py b/skfda/_utils.py index 4ad5b428e..1c6c163d6 100644 --- a/skfda/_utils.py +++ b/skfda/_utils.py @@ -95,7 +95,7 @@ def aliasing_function(*args, **kwargs): return aliasing_function else: - # f is a class (an estimator) + # f is a class class cls(f): pass @@ -104,8 +104,10 @@ class cls(f): init = cls.__init__ cls.__init__ = parameter_aliases(**alias_assignments)(init) - set_params = cls.set_params - cls.set_params = parameter_aliases(**alias_assignments)(set_params) + set_params = getattr(cls, "set_params", None) + if set_params is not None: # For estimators + cls.set_params = parameter_aliases( + **alias_assignments)(set_params) for key, value in alias_assignments.items(): def getter(self): @@ -117,6 +119,10 @@ def setter(self, new_value): for alias in value: setattr(cls, alias, property(getter, setter)) + cls.__name__ = f.__name__ + cls.__doc__ = f.__doc__ + cls.__module__ = f.__module__ + return cls return decorator diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 56ec17387..aab99e1e1 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -18,7 +18,6 @@ from sklearn.base import BaseEstimator, TransformerMixin from skfda.representation.grid import FDataGrid import abc -from abc import abstractclassmethod __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" @@ -49,7 +48,7 @@ def nadaraya_watson(argvals, *, smoothing_parameter=None, kernel (function, optional): kernel function. By default a normal kernel. weights (ndarray, optional): Case weights matrix (in order to modify - the importance of each point in each observation). + the importance of each point). cv (bool, optional): Flag for cross-validation methods. Defaults to False. h (float, optional): same as smoothing_parameter. @@ -119,7 +118,7 @@ def local_linear_regression(argvals, smoothing_parameter, *, kernel (function, optional): kernel function. By default a normal kernel. weights (ndarray, optional): Case weights matrix (in order to modify - the importance of each point in each observation). + the importance of each point). cv (bool, optional): Flag for cross-validation methods. Defaults to False. h (float, optional): same as smoothing_parameter. @@ -179,7 +178,7 @@ def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, kernel (function, optional): kernel function. By default a uniform kernel to perform a 'usual' k nearest neighbours estimation. weights (ndarray, optional): Case weights matrix (in order to modify - the importance of each point in each observation). + the importance of each point). cv (bool, optional): Flag for cross-validation methods. Defaults to False. k (float, optional): same as smoothing_parameter. @@ -205,7 +204,6 @@ def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, [ 0. , 0. , 0.333, 0.333, 0.333], [ 0. , 0. , 0. , 0.5 , 0.5 ]]) - """ # Distances matrix of points in argvals delta_x = np.abs(np.subtract.outer(argvals, argvals)) @@ -282,7 +280,53 @@ def transform(self, X: FDataGrid, y=None): @parameter_aliases(smoothing_parameter=['h', 'bandwidth']) class NadarayaWatsonSmoother(_LinearKernelSmoother): + r"""Nadaraya-Watson smoothing method. + + Uses an smoothing matrix :math:`\hat{H}` for the discretisation + points in argvals by the Nadaraya-Watson estimator. The smoothed + values :math:`\hat{Y}` can be calculated as :math:`\hat{ + Y} = \hat{H}Y` where :math:`Y` is the vector of observations at the + points of discretisation :math:`(x_1, x_2, ..., x_n)`. + + .. math:: + \hat{H}_{i,j} = \frac{K\left(\frac{x_i-x_j}{h}\right)}{\sum_{k=1}^{ + n}K\left( + \frac{x_1-x_k}{h}\right)} + + where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel + window width or smoothing parameter. + Args: + argvals (ndarray): Vector of discretisation points. + smoothing_parameter (float, optional): Window width of the kernel. + kernel (function, optional): kernel function. By default a normal + kernel. + weights (ndarray, optional): Case weights matrix (in order to modify + the importance of each point). + h (float, optional): same as smoothing_parameter. + bandwidth (float, optional): same as smoothing_parameter. + + Examples: + >>> smoother = NadarayaWatsonSmoother(smoothing_parameter=3.5) + >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], + ... data_matrix=[[0,0,0,0,0]])) + >>> smoother.hat_matrix_.round(3) + array([[ 0.294, 0.282, 0.204, 0.153, 0.068], + [ 0.249, 0.259, 0.22 , 0.179, 0.093], + [ 0.165, 0.202, 0.238, 0.229, 0.165], + [ 0.129, 0.172, 0.239, 0.249, 0.211], + [ 0.073, 0.115, 0.221, 0.271, 0.319]]) + >>> smoother = NadarayaWatsonSmoother(h=2) + >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], + ... data_matrix=[[0,0,0,0,0]])) + >>> smoother.hat_matrix_.round(3) + array([[ 0.425, 0.375, 0.138, 0.058, 0.005], + [ 0.309, 0.35 , 0.212, 0.114, 0.015], + [ 0.103, 0.193, 0.319, 0.281, 0.103], + [ 0.046, 0.11 , 0.299, 0.339, 0.206], + [ 0.006, 0.022, 0.163, 0.305, 0.503]]) + + """ def _hat_matrix_function(self): return nadaraya_watson( self.input_points_, @@ -294,6 +338,58 @@ def _hat_matrix_function(self): @parameter_aliases(smoothing_parameter=['h', 'bandwidth']) class LocalLinearRegressionSmoother(_LinearKernelSmoother): + r"""Local linear regression smoothing method. + + Uses an smoothing matrix :math:`\hat{H}` for the discretisation + points in argvals by the local linear regression estimator. The smoothed + values :math:`\hat{Y}` can be calculated as :math:`\hat{ + Y} = \hat{H}Y` where :math:`Y` is the vector of observations at the points + of discretisation :math:`(x_1, x_2, ..., x_n)`. + + .. math:: + \hat{H}_{i,j} = \frac{b_i(x_j)}{\sum_{k=1}^{n}b_k(x_j)} + + .. math:: + b_i(x) = K\left(\frac{x_i - x}{h}\right) S_{n,2}(x) - (x_i - x)S_{n, + 1}(x) + + .. math:: + S_{n,k} = \sum_{i=1}^{n}K\left(\frac{x_i-x}{h}\right)(x_i-x)^k + + where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel + window width. + + Args: + argvals (ndarray): Vector of discretisation points. + smoothing_parameter (float, optional): Window width of the kernel. + kernel (function, optional): kernel function. By default a normal + kernel. + weights (ndarray, optional): Case weights matrix (in order to modify + the importance of each point). + h (float, optional): same as smoothing_parameter. + bandwidth (float, optional): same as smoothing_parameter. + + Examples: + >>> smoother = LocalLinearRegressionSmoother(smoothing_parameter=3.5) + >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], + ... data_matrix=[[0,0,0,0,0]])) + >>> smoother.hat_matrix_.round(3) + array([[ 0.614, 0.429, 0.077, -0.03 , -0.09 ], + [ 0.381, 0.595, 0.168, -0. , -0.143], + [-0.104, 0.112, 0.697, 0.398, -0.104], + [-0.147, -0.036, 0.392, 0.639, 0.152], + [-0.095, -0.079, 0.117, 0.308, 0.75 ]]) + >>> smoother = LocalLinearRegressionSmoother(bandwidth=2) + >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], + ... data_matrix=[[0,0,0,0,0]])) + >>> smoother.hat_matrix_.round(3) + array([[ 0.714, 0.386, -0.037, -0.053, -0.01 ], + [ 0.352, 0.724, 0.045, -0.081, -0.04 ], + [-0.078, 0.052, 0.74 , 0.364, -0.078], + [-0.07 , -0.067, 0.36 , 0.716, 0.061], + [-0.012, -0.032, -0.025, 0.154, 0.915]]) + + """ def _hat_matrix_function(self): return local_linear_regression( @@ -306,6 +402,51 @@ def _hat_matrix_function(self): @parameter_aliases(smoothing_parameter=['k', 'n_neighbors']) class KNeighborsSmoother(_LinearKernelSmoother): + """K-nearest neighbour kernel smoother. + + Uses an smoothing matrix S for the discretisation points in argvals by + the k nearest neighbours estimator. + + Usually used with the uniform kernel, it takes the average of the closest k + points to a given point. + + Args: + argvals (ndarray): Vector of discretisation points. + smoothing_parameter (int, optional): Number of nearest neighbours. By + default it takes the 5% closest points. + kernel (function, optional): kernel function. By default a uniform + kernel to perform a 'usual' k nearest neighbours estimation. + weights (ndarray, optional): Case weights matrix (in order to modify + the importance of each point). + cv (bool, optional): Flag for cross-validation methods. + Defaults to False. + k (float, optional): same as smoothing_parameter. + n_neighbors (float, optional): same as smoothing_parameter. + + Examples: + >>> smoother = KNeighborsSmoother(smoothing_parameter=2) + >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], + ... data_matrix=[[0,0,0,0,0]])) + >>> smoother.hat_matrix_.round(3) + array([[ 0.5, 0.5, 0. , 0. , 0. ], + [ 0.5, 0.5, 0. , 0. , 0. ], + [ 0. , 0. , 0.5, 0.5, 0. ], + [ 0. , 0. , 0.5, 0.5, 0. ], + [ 0. , 0. , 0. , 0.5, 0.5]]) + + In case there are two points at the same distance it will take both. + + >>> smoother = KNeighborsSmoother(smoothing_parameter=2) + >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,3,5,7], + ... data_matrix=[[0,0,0,0,0]])) + >>> smoother.hat_matrix_.round(3) + array([[ 0.5 , 0.5 , 0. , 0. , 0. ], + [ 0.333, 0.333, 0.333, 0. , 0. ], + [ 0. , 0.5 , 0.5 , 0. , 0. ], + [ 0. , 0. , 0.333, 0.333, 0.333], + [ 0. , 0. , 0. , 0.5 , 0.5 ]]) + + """ def _hat_matrix_function(self): return knn( diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 8e01dadf4..7ea161a70 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -42,7 +42,7 @@ class LinearSmootherLeaveOneOutScorer(): def __call__(self, estimator, X, y): y_est = estimator.transform(X) - hat_matrix = estimator._hat_matrix_function() + hat_matrix = estimator.hat_matrix_ return np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) / (1 - hat_matrix.diagonal())) ** 2) @@ -83,7 +83,7 @@ def __init__(self, penalization_function=None): def __call__(self, estimator, X, y): y_est = estimator.transform(X) - hat_matrix = estimator._hat_matrix_function() + hat_matrix = estimator.hat_matrix_ if self.penalization_function is None: def penalization_function(hat_matrix): From e5f13530b976647317e12dfb7efb30a8a0b047a6 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 11 Jun 2019 00:42:26 +0200 Subject: [PATCH 067/222] GridSearch --- skfda/preprocessing/smoothing/validation.py | 111 ++++++++++---------- 1 file changed, 56 insertions(+), 55 deletions(-) diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 7ea161a70..d1eaa2163 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -2,6 +2,7 @@ import numpy as np from . import kernel_smoothers +from sklearn.model_selection import GridSearchCV __author__ = "Miguel Carbajo Berrocal" @@ -35,7 +36,8 @@ class LinearSmootherLeaveOneOutScorer(): y (FDataGrid): Functional data target. Should be the same as X. Returns: - float: Cross validation score. + float: Cross validation score, with negative sign, as it is a + penalization. """ @@ -44,8 +46,8 @@ def __call__(self, estimator, X, y): hat_matrix = estimator.hat_matrix_ - return np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) - / (1 - hat_matrix.diagonal())) ** 2) + return -np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) + / (1 - hat_matrix.diagonal())) ** 2) class LinearSmootherGeneralizedCVScorer(): @@ -74,7 +76,8 @@ class LinearSmootherGeneralizedCVScorer(): y (FDataGrid): Functional data target. Should be the same as X. Returns: - float: Cross validation score. + float: Cross validation score, with negative sign, as it is a + penalization. """ def __init__(self, penalization_function=None): @@ -91,15 +94,15 @@ def penalization_function(hat_matrix): else: penalization_function = self.penalization_function - return (np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) - / (1 - hat_matrix.diagonal())) ** 2) - * penalization_function(hat_matrix)) + return -(np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) + / (1 - hat_matrix.diagonal())) ** 2) + * penalization_function(hat_matrix)) -def minimize(fdatagrid, parameters, - smoothing_method=None, - cv_method=None): - """Chooses the best smoothness parameter and performs smoothing. +def optimize_smoothing_parameter(fdatagrid, parameter_values, + smoothing_method=None, + cv_method=None): + """Chooses the best smoothing parameter and performs smoothing. Performs the smoothing of a FDataGrid object choosing the best parameter of a given list using a cross validation scoring method. @@ -142,12 +145,12 @@ def minimize(fdatagrid, parameters, >>> import skfda >>> x = np.linspace(-2, 2, 5) >>> fd = skfda.FDataGrid(x ** 2, x) - >>> res = minimize(fd, [2,3], + >>> res = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother()) >>> np.array(res['scores']).round(2) - array([ 11.67, 12.37]) + array([-11.67, -12.37]) >>> round(res['best_score'], 2) - 11.67 + -11.67 >>> res['best_parameter'] 2 >>> res['hat_matrix'].round(2) @@ -170,31 +173,33 @@ def minimize(fdatagrid, parameters, Other validation methods can be used such as cross-validation or general cross validation using other penalization functions. - >>> res = minimize(fd, [2,3], + >>> res = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherLeaveOneOutScorer()) >>> np.array(res['scores']).round(2) - array([ 4.2, 5.5]) - >>> res = minimize(fd, [2,3], + array([-4.2, -5.5]) + >>> res = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), - ... cv_method=LinearSmootherGeneralizedCVScorer(aic)) + ... cv_method=LinearSmootherGeneralizedCVScorer( + ... akaike_information_criterion)) >>> np.array(res['scores']).round(2) - array([ 9.35, 10.71]) - >>> res = minimize(fd, [2,3], + array([ -9.35, -10.71]) + >>> res = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), - ... cv_method=LinearSmootherGeneralizedCVScorer(fpe)) + ... cv_method=LinearSmootherGeneralizedCVScorer( + ... finite_prediction_error)) >>> np.array(res['scores']).round(2) - array([ 9.8, 11. ]) - >>> res = minimize(fd, [2,3], + array([ -9.8, -11. ]) + >>> res = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherGeneralizedCVScorer(shibata)) >>> np.array(res['scores']).round(2) - array([ 7.56, 9.17]) - >>> res = minimize(fd, [2,3], + array([-7.56, -9.17]) + >>> res = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherGeneralizedCVScorer(rice)) >>> np.array(res['scores']).round(2) - array([ 21. , 16.5]) + array([-21. , -16.5]) """ if fdatagrid.ndim_domain != 1: @@ -212,46 +217,41 @@ def minimize(fdatagrid, parameters, if cv_method is None: cv_method = LinearSmootherGeneralizedCVScorer() - # Reduce one dimension the sample points. - scores = [] - - # Calculates the scores for each parameter. - for h in parameters: - smoothing_method.smoothing_parameter = h - smoothing_method.fit(fdatagrid) - scores.append(cv_method(smoothing_method, fdatagrid, fdatagrid)) - - # gets the best parameter. - h = parameters[int(np.argmin(scores))] - smoothing_method.smoothing_parameter = h - smoothing_method.fit(fdatagrid) + grid = GridSearchCV(estimator=smoothing_method, + param_grid={'smoothing_parameter': parameter_values}, + scoring=cv_method, cv=[(slice(None), slice(None))]) + grid.fit(fdatagrid, fdatagrid) + scores = grid.cv_results_['mean_test_score'] + best_score = grid.best_score_ + best_parameter = grid.best_params_['smoothing_parameter'] + best_estimator = grid.best_estimator_ return {'scores': scores, - 'best_score': np.min(scores), - 'best_parameter': h, - 'hat_matrix': smoothing_method._hat_matrix_function(), - 'fdatagrid': smoothing_method.transform(fdatagrid) + 'best_score': best_score, + 'best_parameter': best_parameter, + 'hat_matrix': best_estimator.hat_matrix_, + 'fdatagrid': best_estimator.transform(fdatagrid) } -def aic(s_matrix): +def akaike_information_criterion(hat_matrix): r"""Akaike's information criterion for cross validation. .. math:: \Xi(\nu,n) = \exp\left(2 * \frac{tr(\hat{H}^\nu)}{n}\right) Args: - s_matrix (numpy.darray): Smoothing matrix whose penalization + hat_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: float: penalization given by the Akaike's information criterion. """ - return np.exp(2 * s_matrix.diagonal().mean()) + return np.exp(2 * hat_matrix.diagonal().mean()) -def fpe(s_matrix): +def finite_prediction_error(hat_matrix): r"""Finite prediction error for cross validation. .. math:: @@ -259,45 +259,46 @@ def fpe(s_matrix): \frac{tr(\hat{H}^\nu)}{n}} Args: - s_matrix (numpy.darray): Smoothing matrix whose penalization + hat_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: float: penalization given by the finite prediction error. """ - return (1 + s_matrix.diagonal().mean()) / (1 - s_matrix.diagonal().mean()) + return ((1 + hat_matrix.diagonal().mean()) + / (1 - hat_matrix.diagonal().mean())) -def shibata(s_matrix): +def shibata(hat_matrix): r"""Shibata's model selector for cross validation. .. math:: \Xi(\nu,n) = 1 + 2 * \frac{tr(\hat{H}^\nu)}{n} Args: - s_matrix (numpy.darray): Smoothing matrix whose penalization + hat_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: float: penalization given by the Shibata's model selector. """ - return 1 + 2 * s_matrix.diagonal().mean() + return 1 + 2 * hat_matrix.diagonal().mean() -def rice(s_matrix): +def rice(hat_matrix): r"""Rice's bandwidth selector for cross validation. .. math:: \Xi(\nu,n) = \left(1 - 2 * \frac{tr(\hat{H}^\nu)}{n}\right)^{-1} Args: - s_matrix (numpy.darray): Smoothing matrix whose penalization + hat_matrix (numpy.darray): Smoothing matrix whose penalization score is desired. Returns: float: penalization given by the Rice's bandwidth selector. """ - return (1 - 2 * s_matrix.diagonal().mean()) ** -1 + return (1 - 2 * hat_matrix.diagonal().mean()) ** -1 From e70022ebcd5540812b39a55c3569ed9d9022b5ac Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 11 Jun 2019 01:05:03 +0200 Subject: [PATCH 068/222] Plot kernel smoothing --- examples/plot_kernel_smoothing.py | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/examples/plot_kernel_smoothing.py b/examples/plot_kernel_smoothing.py index 7bb52d3e5..ace636cfa 100644 --- a/examples/plot_kernel_smoothing.py +++ b/examples/plot_kernel_smoothing.py @@ -40,15 +40,15 @@ param_values = np.linspace(start=2, stop=25, num=24) # Local linear regression kernel smoothing. -llr = val.minimise(fd, param_values, - smoothing_method=ks.local_linear_regression) +llr = val.optimize_smoothing_parameter( + fd, param_values, smoothing_method=ks.LocalLinearRegressionSmoother()) # Nadaraya-Watson kernel smoothing. -nw = skfda.preprocessing.smoothing.validation.minimise( - fd, param_values, smoothing_method=ks.nadaraya_watson) +nw = skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter( + fd, param_values, smoothing_method=ks.NadarayaWatsonSmoother()) # K-nearest neighbours kernel smoothing. -knn = skfda.preprocessing.smoothing.validation.minimise( - fd, param_values, smoothing_method=ks.knn) +knn = skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter( + fd, param_values, smoothing_method=ks.KNeighborsSmoother()) plt.plot(param_values, knn['scores']) plt.plot(param_values, llr['scores']) @@ -99,20 +99,14 @@ # We can also appreciate the effects of undersmoothing and oversmoothing in # the following plots. -fd_us = skfda.FDataGrid( - ks.nadaraya_watson(fd.sample_points, h=2).dot(fd.data_matrix[10, ..., 0]), - fd.sample_points, fd.sample_range, fd.dataset_label, - fd.axes_labels) -fd_os = skfda.FDataGrid( - ks.nadaraya_watson(fd.sample_points, h=15).dot(fd.data_matrix[10, ..., 0]), - fd.sample_points, fd.sample_range, fd.dataset_label, - fd.axes_labels) +fd_us = ks.NadarayaWatsonSmoother(h=2).fit_transform(fd[10]) +fd_os = ks.NadarayaWatsonSmoother(h=15).fit_transform(fd[10]) # Under-smoothed fd[10].scatter(s=0.5) -fd_us.plot(c='sandybrown') +fd_us.plot() # Over-smoothed plt.figure() fd[10].scatter(s=0.5) -fd_os.plot(c='r') +fd_os.plot() From cae50b42acd847fd8e4b40d9e742d66ff46f91c7 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 11 Jun 2019 12:02:09 +0200 Subject: [PATCH 069/222] Correct bug: kernel not passed --- skfda/preprocessing/smoothing/kernel_smoothers.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index aab99e1e1..751d0bb3d 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -331,6 +331,7 @@ def _hat_matrix_function(self): return nadaraya_watson( self.input_points_, smoothing_parameter=self.smoothing_parameter, + kernel=self.kernel, weights=self.weights) return self @@ -395,6 +396,7 @@ def _hat_matrix_function(self): return local_linear_regression( self.input_points_, smoothing_parameter=self.smoothing_parameter, + kernel=self.kernel, weights=self.weights) return self @@ -452,6 +454,7 @@ def _hat_matrix_function(self): return knn( self.input_points_, smoothing_parameter=self.smoothing_parameter, + kernel=self.kernel, weights=self.weights) return self From 4be327c5455c06e5e0a38db26d814dc1e039ad47 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 11 Jun 2019 12:21:06 +0200 Subject: [PATCH 070/222] Bug: default knn kernel --- skfda/preprocessing/smoothing/kernel_smoothers.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 751d0bb3d..234d0c2ef 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -449,6 +449,11 @@ class KNeighborsSmoother(_LinearKernelSmoother): [ 0. , 0. , 0. , 0.5 , 0.5 ]]) """ + def __init__(self, *, smoothing_parameter=None, + kernel=kernels.uniform, weights=None): + self.smoothing_parameter = smoothing_parameter + self.kernel = kernel + self.weights = weights def _hat_matrix_function(self): return knn( From c90238e4300f2a0e6789eda78ee673a3383d238d Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Tue, 11 Jun 2019 15:14:35 +0200 Subject: [PATCH 071/222] Added flake 8 to test pep8 --- .travis.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 5a15561d0..64265da1c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,8 +15,10 @@ matrix: language: shell # 'language: python' is an error on Travis CI Windows before_install: choco install python env: PATH=/c/Python37:/c/Python37/Scripts:$PATH -install: pip3 install --upgrade pip cython numpy || pip3 install --upgrade --user pip cython numpy # all three OSes agree about 'pip3' +install: pip3 install --upgrade pip cython numpy flake8 || pip3 install --upgrade --user pip cython numpy flake8 # all three OSes agree about 'pip3' # 'python' points to Python 2.7 on macOS but points to Python 3.7 on Linux and Windows # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only -script: python3 setup.py test || python setup.py test +script: + python3 setup.py test || python setup.py test + flake8 skfda From 4d524af11d8b6bbc08874d550c8f26d56669a4df Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Tue, 11 Jun 2019 15:17:27 +0200 Subject: [PATCH 072/222] New changes on flake 8 --- .travis.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 64265da1c..ebd612084 100644 --- a/.travis.yml +++ b/.travis.yml @@ -20,5 +20,5 @@ install: pip3 install --upgrade pip cython numpy flake8 || pip3 install --upgrad # 'python' points to Python 2.7 on macOS but points to Python 3.7 on Linux and Windows # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only script: - python3 setup.py test || python setup.py test - flake8 skfda + - python3 setup.py test || python setup.py test + - flake8 skfda From 4fc2dbae271d38084af4bc0481593212d053709e Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Tue, 11 Jun 2019 15:24:04 +0200 Subject: [PATCH 073/222] Added flake 8 for not returning 0 --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ebd612084..dc36a3ce5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,4 +21,4 @@ install: pip3 install --upgrade pip cython numpy flake8 || pip3 install --upgrad # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only script: - python3 setup.py test || python setup.py test - - flake8 skfda + - flake8 --exit-zero skfda From 892e62745de34feaf5d6217ca7d9c07dc6ddbdd8 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Tue, 11 Jun 2019 15:40:05 +0200 Subject: [PATCH 074/222] Added code coverage --- .travis.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index dc36a3ce5..b31c8a0bc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,10 +15,14 @@ matrix: language: shell # 'language: python' is an error on Travis CI Windows before_install: choco install python env: PATH=/c/Python37:/c/Python37/Scripts:$PATH -install: pip3 install --upgrade pip cython numpy flake8 || pip3 install --upgrade --user pip cython numpy flake8 # all three OSes agree about 'pip3' +install: pip3 install --upgrade pip cython numpy flake8 codecov || pip3 install --upgrade --user pip cython numpy flake8 codecov # all three OSes agree about 'pip3' # 'python' points to Python 2.7 on macOS but points to Python 3.7 on Linux and Windows # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only script: - python3 setup.py test || python setup.py test - flake8 --exit-zero skfda + - coverage run setup.py test + +after_success: + - codecov \ No newline at end of file From 38290f3e2e9fc0b51a6fe18146603841a5c4ec78 Mon Sep 17 00:00:00 2001 From: pablomm Date: Wed, 12 Jun 2019 12:54:12 +0200 Subject: [PATCH 075/222] Doc typos --- examples/plot_elastic_registration.py | 2 +- examples/plot_extrapolation.py | 12 ++++++------ .../registration/_registration_utils.py | 12 ++++++------ skfda/representation/extrapolation.py | 8 ++++---- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/examples/plot_elastic_registration.py b/examples/plot_elastic_registration.py index 7046e823d..bcee83d36 100644 --- a/examples/plot_elastic_registration.py +++ b/examples/plot_elastic_registration.py @@ -64,7 +64,7 @@ # In general these type of alignments are not possible, in the following # figure it is shown how it works with a real dataset. # The :func:`berkeley growth dataset` -# contains the growth curves of a set childs, in this case will be used only the +# contains the growth curves of a set children, in this case will be used only the # males. The growth curves will be resampled using cubic interpolation and derived # to obtain the velocity curves. # diff --git a/examples/plot_extrapolation.py b/examples/plot_extrapolation.py index 541973bca..1d64ff71f 100644 --- a/examples/plot_extrapolation.py +++ b/examples/plot_extrapolation.py @@ -19,18 +19,18 @@ # # The extrapolation defines how to evaluate points that are # outside the domain range of a -# :class:`FDataBasis ` or a -# :class:`FDataGrid `. +# :class:`FDataBasis ` or a +# :class:`FDataGrid `. # -# The :class:`FDataBasis ` objects have a +# The :class:`FDataBasis ` objects have a # predefined extrapolation which is applied in ´evaluate´ # if the argument `extrapolation` is not supplied. This default value # could be specified when the object is created or changing the # attribute `extrapolation`. # # The extrapolation could be specified by a string with the short name of an -# extrapolator, with an -# :class:´Extrapolator ´ or with a callable. +# extrapolator or with an +# :class:´Extrapolator ´. # # To show how it works we will create a dataset with two unidimensional curves # defined in (0,1), and we will represent it using a grid and different types of @@ -153,7 +153,7 @@ ############################################################################### # -# The :class:´FillExtrapolation ´ will fill +# The :class:´FillExtrapolation ´ will fill # the points extrapolated with the same value. The case of filling with zeros # could be specified with the string `"zeros"`, which is equivalent to # `extrapolation=FillExtrapolation(0)`. diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index ba9762135..3f98a8dec 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -216,9 +216,9 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, def invert_warping(fdatagrid, *, eval_points=None): r"""Compute the inverse of a diffeomorphism. - Let :math:`\gamma : [a,b] \\rightarrow [a,b]` be a function strictly + Let :math:`\gamma : [a,b] \rightarrow [a,b]` be a function strictly increasing, calculates the corresponding inverse - :math:`\gamma^{-1} : [a,b] \\rightarrow [a,b]` such that + :math:`\gamma^{-1} : [a,b] \rightarrow [a,b]` such that :math:`\gamma^{-1} \circ \gamma = \gamma \circ \gamma^{-1} = \gamma_{id}`. Uses a PCHIP interpolator to compute approximately the inverse. @@ -304,12 +304,12 @@ def _normalize_scale(t, a=0, b=1): def normalize_warping(warping, domain_range=None): - """Rescale a warping to normalize their domain. + r"""Rescale a warping to normalize their domain. - Given a set of warpings :math:`\\gamma_i:[a,b] \\rightarrow [a,b]` it is + Given a set of warpings :math:`\gamma_i:[a,b]\rightarrow [a,b]` it is used an affine traslation to change the domain of the transformation to - other domain, :math:`\\hat \\gamma_i:[\\hat a,\\hat b] \\rightarrow - [\\hat a, \\hat b]`. + other domain, :math:`\tilde \gamma_i:[\tilde a,\tilde b] \rightarrow + [\tilde a, \tilde b]`. Args: warping (:class:`FDatagrid`): Set of warpings to rescale. diff --git a/skfda/representation/extrapolation.py b/skfda/representation/extrapolation.py index 8a592cd3d..bf636aae4 100644 --- a/skfda/representation/extrapolation.py +++ b/skfda/representation/extrapolation.py @@ -36,7 +36,7 @@ class PeriodicExtrapolation(EvaluatorConstructor): """ def evaluator(self, fdata): - """Returns the evaluator used by class:`FData`. + """Returns the evaluator used by :class:`FData`. Returns: (:class:`Evaluator`): Evaluator of the periodic extrapolation. @@ -101,7 +101,7 @@ class BoundaryExtrapolation(EvaluatorConstructor): """ def evaluator(self, fdata): - """Returns the evaluator used by class:`FData`. + """Returns the evaluator used by :class:`FData`. Returns: (:class:`Evaluator`): Evaluator of the periodic boundary. @@ -173,7 +173,7 @@ class ExceptionExtrapolation(EvaluatorConstructor): """ def evaluator(self, fdata): - """Returns the evaluator used by class:`FData`. + """Returns the evaluator used by :class:`FData`. Returns: (:class:`Evaluator`): Evaluator of the periodic extrapolation. @@ -229,7 +229,7 @@ class FillExtrapolation(EvaluatorConstructor): """ def __init__(self, fill_value): - """Returns the evaluator used by class:`FData`. + """Returns the evaluator used by :class:`FData`. Returns: (:class:`Evaluator`): Evaluator of the periodic extrapolation. From 4b4086955bef8210e88629c3bb85a5bb5ee34673 Mon Sep 17 00:00:00 2001 From: pablomm Date: Wed, 12 Jun 2019 13:13:40 +0200 Subject: [PATCH 076/222] Replaced \ -> \ and added raw docstrings --- skfda/datasets/_samples_generators.py | 14 +-- skfda/misc/metrics.py | 40 ++++---- skfda/preprocessing/registration/_elastic.py | 98 +++++++++---------- .../registration/_registration_utils.py | 2 +- 4 files changed, 77 insertions(+), 77 deletions(-) diff --git a/skfda/datasets/_samples_generators.py b/skfda/datasets/_samples_generators.py index ee72dcc9c..cf849c8d3 100644 --- a/skfda/datasets/_samples_generators.py +++ b/skfda/datasets/_samples_generators.py @@ -167,7 +167,7 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, noise: float=.0, modes_location=None, random_state=None): - """Generate multimodal samples. + r"""Generate multimodal samples. Each sample :math:`x_i(t)` is proportional to a gaussian mixture, generated as the sum of multiple pdf of multivariate normal distributions with @@ -175,11 +175,11 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, .. math:: - x_i(t) \\propto \\sum_{n=1}^{\\text{n_modes}} \\exp \\left ( - {-\\frac{1}{2\\sigma} (t-\\mu_n)^T \\mathbb{1} (t-\\mu_n)} \\right ) + x_i(t) \propto \sum_{n=1}^{\text{n_modes}} \exp \left ( + {-\frac{1}{2\sigma} (t-\mu_n)^T \mathbb{1} (t-\mu_n)} \right ) - Where :math:`\\mu_n=\\text{mode_location}_n+\\epsilon` and :math:`\\epsilon` - is normally distributed, with mean :math:`\\mathbb{0}` and standard + Where :math:`\mu_n=\text{mode_location}_n+\epsilon` and :math:`\epsilon` + is normally distributed, with mean :math:`\mathbb{0}` and standard deviation given by the parameter `std`. Args: @@ -188,7 +188,7 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, points_per_dim: Points per sample. If the object is multidimensional indicates the number of points for each dimension in the domain. The sample will have :math: - `\\text{points_per_dim}^\\text{ndim_domain}` points of + `\text{points_per_dim}^\text{ndim_domain}` points of discretization. ndim_domain: Number of dimensions of the domain. ndim_image: Number of dimensions of the image @@ -197,7 +197,7 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, stop: Ending point of the samples. In multidimensional objects the ending point of each axis. std: Standard deviation of the variation of the modes location. - mode_std: Standard deviation :math:`\\sigma` of each mode. + mode_std: Standard deviation :math:`\sigma` of each mode. noise: Standard deviation of Gaussian noise added to the data. modes_location: List of coordinates of each mode. random_state: Random state. diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 08db349cb..2fe55e8f8 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -10,7 +10,7 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None): - """Checks if the fdatas passed as argument are unidimensional and compatible + r"""Checks if the fdatas passed as argument are unidimensional and compatible and converts them to FDatagrid to compute their distances. @@ -346,16 +346,16 @@ def lp_distance(fdata1, fdata2, p=2, *, eval_points=None): def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): - """Compute the Fisher-Rao distance btween two functional objects. + r"""Compute the Fisher-Rao distance btween two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let :math:`q_i` and :math:`q_j` be the corresponding SRSF (see :func:`to_srsf`), the fisher rao distance is defined as .. math:: - d_{FR}(f_i, f_j) = \\| q_i - q_j \\|_2 = - \\left ( \\int_0^1 sgn(\\dot{f_i}(t))\\sqrt{|\\dot{f_i}(t)|} - - sgn(\\dot{f_j}(t))\\sqrt{|\\dot{f_j}(t)|} dt \\right )^{\\frac{1}{2}} + d_{FR}(f_i, f_j) = \| q_i - q_j \|_2 = + \left ( \int_0^1 sgn(\dot{f_i}(t))\sqrt{|\dot{f_i}(t)|} - + sgn(\dot{f_j}(t))\sqrt{|\dot{f_j}(t)|} dt \right )^{\frac{1}{2}} If the observations are distributions of random variables the distance will match with the usual fisher-rao distance in non-parametric form for @@ -400,28 +400,28 @@ def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): return lp_distance(fdata1_srsf, fdata2_srsf, p=2) def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): - """Compute the amplitude distance between two functional objects. + r"""Compute the amplitude distance between two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let :math:`q_i` and :math:`q_j` be the corresponding SRSF (see :func:`to_srsf`), the amplitude distance is defined as .. math:: - d_{A}(f_i, f_j)=min_{\\gamma \\in \\Gamma}d_{FR}(f_i \\circ \\gamma,f_j) + d_{A}(f_i, f_j)=min_{\gamma \in \Gamma}d_{FR}(f_i \circ \gamma,f_j) A penalty term could be added to restrict the ammount of elasticity in the alignment used. .. math:: - d_{\\lambda}^2(f_i, f_j) =min_{\\gamma \\in \\Gamma} \\{ - d_{FR}^2(f_i \\circ \\gamma, f_j) + \\lambda \\mathcal{R}(\\gamma) \\} + d_{\lambda}^2(f_i, f_j) =min_{\gamma \in \Gamma} \{ + d_{FR}^2(f_i \circ \gamma, f_j) + \lambda \mathcal{R}(\gamma) \} Where :math:`d_{FR}` is the Fisher-Rao distance and the penalty term is given by .. math:: - \\mathcal{R}(\\gamma) = \\|\\sqrt{\\dot{\\gamma}}- 1 \\|_{\\mathbb{L}^2}^2 + \mathcal{R}(\gamma) = \|\sqrt{\dot{\gamma}}- 1 \|_{\mathbb{L}^2}^2 See [SK16-4-10-1]_ for a detailed explanation. @@ -488,17 +488,17 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): return distance def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): - """Compute the amplitude distance btween two functional objects. + r"""Compute the amplitude distance btween two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let - :math:`\\gamma_{ij}` the corresponding warping used in the elastic + :math:`\gamma_{ij}` the corresponding warping used in the elastic registration to align :math:`f_i` to :math:`f_j` (see :func:`elastic_registration`). The phase distance between :math:`f_i` and :math:`f_j` is defined as .. math:: - d_{P}(f_i, f_j) = d_{FR}(\\gamma_{ij}, \\gamma_{id}) = - arcos \\left ( \\int_0^1 \\sqrt {\\dot \\gamma_{ij}(t)} dt \\right ) + d_{P}(f_i, f_j) = d_{FR}(\gamma_{ij}, \gamma_{id}) = + arcos \left ( \int_0^1 \sqrt {\dot \gamma_{ij}(t)} dt \right ) See [SK16-4-10-2]_ for a detailed explanation. @@ -554,17 +554,17 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): def warping_distance(warping1, warping2, *, eval_points=None): - """Compute the distance between warpings functions. + r"""Compute the distance between warpings functions. - Let :math:`\\gamma_i` and :math:`\\gamma_j` be two warpings, defined in - :math:`\\gamma_i:[a,b] \\rightarrow [a,b]`. The distance in the - space of warping functions, :math:`\\Gamma`, with the riemannian metric + Let :math:`\gamma_i` and :math:`\gamma_j` be two warpings, defined in + :math:`\gamma_i:[a,b] \rightarrow [a,b]`. The distance in the + space of warping functions, :math:`\Gamma`, with the riemannian metric given by the fisher-rao inner product can be computed using the structure of hilbert sphere in their srsf's. .. math:: - d_{\\Gamma}(\\gamma_i, \\gamma_j) = cos^{-1} \\left ( \\int_0^1 - \\sqrt{\\dot \\gamma_i(t)\\dot \\gamma_j(t)}dt \\right ) + d_{\Gamma}(\gamma_i, \gamma_j) = cos^{-1} \left ( \int_0^1 + \sqrt{\dot \gamma_i(t)\dot \gamma_j(t)}dt \right ) See [SK16-4-11-2]_ for a detailed explanation. diff --git a/skfda/preprocessing/registration/_elastic.py b/skfda/preprocessing/registration/_elastic.py index dff04edef..a3f80d938 100644 --- a/skfda/preprocessing/registration/_elastic.py +++ b/skfda/preprocessing/registration/_elastic.py @@ -21,17 +21,17 @@ def to_srsf(fdatagrid, eval_points=None): - """Calculate the square-root slope function (SRSF) transform. + r"""Calculate the square-root slope function (SRSF) transform. - Let :math:`f_i : [a,b] \\rightarrow \\mathbb{R}` be an absolutely continuous + Let :math:`f_i : [a,b] \rightarrow \mathbb{R}` be an absolutely continuous function, the SRSF transform is defined as .. math:: - SRSF(f_i(t)) = sgn(f_i(t)) \\sqrt{|Df_i(t)|} = q_i(t) + SRSF(f_i(t)) = sgn(f_i(t)) \sqrt{|Df_i(t)|} = q_i(t) This representation it is used to compute the extended non-parametric Fisher-Rao distance between functions, wich under the SRSF representation - becomes the usual :math:`\\mathbb{L}^2` distance between functions. + becomes the usual :math:`\mathbb{L}^2` distance between functions. See [SK16-4-6-1]_ . Args: @@ -76,23 +76,23 @@ def to_srsf(fdatagrid, eval_points=None): def from_srsf(fdatagrid, initial=None, *, eval_points=None): - """Given a SRSF calculate the corresponding function in the original space. + r"""Given a SRSF calculate the corresponding function in the original space. - Let :math:`f_i : [a,b]\\rightarrow \\mathbb{R}` be an absolutely continuous + Let :math:`f_i : [a,b]\rightarrow \mathbb{R}` be an absolutely continuous function, the SRSF transform is defined as .. math:: - SRSF(f_i(t)) = sgn(f_i(t)) \\sqrt{|Df_i(t)|} = q_i(t) + SRSF(f_i(t)) = sgn(f_i(t)) \sqrt{|Df_i(t)|} = q_i(t) This transformation is a mapping up to constant. Given the srsf and the initial value the original function can be obtained as .. math:: - f_i(t) = f(a) + \\int_{a}^t q(t)|q(t)|dt + f_i(t) = f(a) + \int_{a}^t q(t)|q(t)|dt This representation it is used to compute the extended non-parametric Fisher-Rao distance between functions, wich under the SRSF representation - becomes the usual :math:`\\mathbb{L}^2` distance between functions. + becomes the usual :math:`\mathbb{L}^2` distance between functions. See [SK16-4-6-2]_ . Args: @@ -146,7 +146,7 @@ def from_srsf(fdatagrid, initial=None, *, eval_points=None): def _elastic_alignment_array(template_data, q_data, eval_points, lam, grid_dim): - """Wrapper between the cython interface and python. + r"""Wrapper between the cython interface and python. Selects the corresponding routine depending on the dimensions of the arrays. @@ -181,34 +181,34 @@ def _elastic_alignment_array(template_data, q_data, eval_points, lam, grid_dim): def elastic_registration_warping(fdatagrid, template=None, *, lam=0., eval_points=None, fdatagrid_srsf=None, template_srsf=None, grid_dim=7, **kwargs): - """Calculate the warping to align a FDatagrid using the SRSF framework. + r"""Calculate the warping to align a FDatagrid using the SRSF framework. Let :math:`f` be a function of the functional data object wich will be aligned to the template :math:`g`. Calculates the warping wich minimises the Fisher-Rao distance between :math:`g` and the registered function - :math:`f^*(t)=f(\\gamma^*(t))=f \\circ \\gamma^*`. + :math:`f^*(t)=f(\gamma^*(t))=f \circ \gamma^*`. .. math:: - \\gamma^* = argmin_{\\gamma \\in \\Gamma} d_{\\lambda}(f \\circ - \\gamma, g) + \gamma^* = argmin_{\gamma \in \Gamma} d_{\lambda}(f \circ + \gamma, g) - Where :math:`d_{\\lambda}` denotes the extended amplitude distance with a + Where :math:`d_{\lambda}` denotes the extended amplitude distance with a penalty term, used to control the amount of warping. .. math:: - d_{\\lambda}^2(f \\circ \\gamma, g) = \\| SRSF(f \\circ \\gamma) - \\sqrt{\\dot{\\gamma}} - SRSF(g)\\|_{\\mathbb{L}^2}^2 + \\lambda - \\mathcal{R}(\\gamma) + d_{\lambda}^2(f \circ \gamma, g) = \| SRSF(f \circ \gamma) + \sqrt{\dot{\gamma}} - SRSF(g)\|_{\mathbb{L}^2}^2 + \lambda + \mathcal{R}(\gamma) In the implementation it is used as penalty term .. math:: - \\mathcal{R}(\\gamma) = \\|\\sqrt{\\dot{\\gamma}}- 1 \\|_{\\mathbb{L}^2}^2 + \mathcal{R}(\gamma) = \|\sqrt{\dot{\gamma}}- 1 \|_{\mathbb{L}^2}^2 Wich restrict the amount of elasticity employed in the alignment. The registered function :math:`f^*(t)` can be calculated using the - composition :math:`f^*(t)=f(\\gamma^*(t))`. + composition :math:`f^*(t)=f(\gamma^*(t))`. If the template is not specified it is used the Karcher mean of the set of functions under the Fisher-Rao metric to perform the alignment, wich is @@ -298,34 +298,34 @@ def elastic_registration_warping(fdatagrid, template=None, *, lam=0., def elastic_registration(fdatagrid, template=None, *, lam=0., eval_points=None, fdatagrid_srsf=None, template_srsf=None, grid_dim=7, **kwargs): - """Align a FDatagrid using the SRSF framework. + r"""Align a FDatagrid using the SRSF framework. Let :math:`f` be a function of the functional data object wich will be aligned to the template :math:`g`. Calculates the warping wich minimises the Fisher-Rao distance between :math:`g` and the registered function - :math:`f^*(t)=f(\\gamma^*(t))=f \\circ \\gamma^*`. + :math:`f^*(t)=f(\gamma^*(t))=f \circ \gamma^*`. .. math:: - \\gamma^* = argmin_{\\gamma \\in \\Gamma} d_{\\lambda}(f \\circ - \\gamma, g) + \gamma^* = argmin_{\gamma \in \Gamma} d_{\lambda}(f \circ + \gamma, g) - Where :math:`d_{\\lambda}` denotes the extended Fisher-Rao distance with a + Where :math:`d_{\lambda}` denotes the extended Fisher-Rao distance with a penalty term, used to control the amount of warping. .. math:: - d_{\\lambda}^2(f \\circ \\gamma, g) = \\| SRSF(f \\circ \\gamma) - \\sqrt{\\dot{\\gamma}} - SRSF(g)\\|_{\\mathbb{L}^2}^2 + \\lambda - \\mathcal{R}(\\gamma) + d_{\lambda}^2(f \circ \gamma, g) = \| SRSF(f \circ \gamma) + \sqrt{\dot{\gamma}} - SRSF(g)\|_{\mathbb{L}^2}^2 + \lambda + \mathcal{R}(\gamma) In the implementation it is used as penalty term .. math:: - \\mathcal{R}(\\gamma) = \\|\\sqrt{\\dot{\\gamma}}- 1 \\|_{\\mathbb{L}^2}^2 + \mathcal{R}(\gamma) = \|\sqrt{\dot{\gamma}}- 1 \|_{\mathbb{L}^2}^2 Wich restrict the amount of elasticity employed in the alignment. The registered function :math:`f^*(t)` can be calculated using the - composition :math:`f^*(t)=f(\\gamma^*(t))`. + composition :math:`f^*(t)=f(\gamma^*(t))`. If the template is not specified it is used the Karcher mean of the set of functions under the elastic metric to perform the alignment, wich is @@ -383,20 +383,20 @@ def elastic_registration(fdatagrid, template=None, *, lam=0., eval_points=None, def warping_mean(warping, *, iter=20, tol=1e-5, step_size=1., eval_points=None, return_shooting=False): - """Compute the karcher mean of a set of warpings. + r"""Compute the karcher mean of a set of warpings. - Let :math:`\\gamma_i i=1...n` be a set of warping functions - :math:`\\gamma_i:[a,b] \\rightarrow [a,b]` in :math:`\\Gamma`, i.e., - monotone increasing and with the restriction :math:`\\gamma_i(a)=a \\, - \\gamma_i(b)=b`. + Let :math:`\gamma_i i=1...n` be a set of warping functions + :math:`\gamma_i:[a,b] \rightarrow [a,b]` in :math:`\Gamma`, i.e., + monotone increasing and with the restriction :math:`\gamma_i(a)=a \, + \gamma_i(b)=b`. - The karcher mean :math:`\\bar \\gamma` is defined as the warping that + The karcher mean :math:`\bar \gamma` is defined as the warping that minimises locally the sum of Fisher-Rao squared distances. [SK16-8-3-2]_. .. math:: - \\bar \\gamma = argmin_{\\gamma \\in \\Gamma} \\sum_{i=1}^{n} - d_{FR}^2(\\gamma, \\gamma_i) + \bar \gamma = argmin_{\gamma \in \Gamma} \sum_{i=1}^{n} + d_{FR}^2(\gamma, \gamma_i) The computation is performed using the structure of Hilbert Sphere obtained after a transformation of the warpings, see [S11-3-3]_. @@ -405,9 +405,9 @@ def warping_mean(warping, *, iter=20, tol=1e-5, step_size=1., eval_points=None, warping (:class:`FDataGrid`): Set of warpings. iter (int): Maximun number of interations. Defaults to 20. tol (float): Convergence criterion, if the norm of the mean of the - shooting vectors, :math:`| \\bar v |>> from skfda import FDataGrid >>> from skfda.preprocessing.registration import invert_warping - We will construct the warping :math:`\gamma : [0,1] \\rightarrow [0,1]` + We will construct the warping :math:`\gamma : [0,1] \rightarrow [0,1]` wich maps t to t^3. >>> t = np.linspace(0, 1) From 6057bf21bccfb019ea63605f4ee2ce8354b97754 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 14 Jun 2019 14:17:41 +0200 Subject: [PATCH 077/222] Changes to kernel smoothing * Redundant matrix functions removed * Parameter aliases removed * A `GridSearchCV` object is now returned from `optimize_smoothing_parameter` --- examples/plot_kernel_smoothing.py | 28 +- .../smoothing/kernel_smoothers.py | 343 +++++------------- skfda/preprocessing/smoothing/validation.py | 54 +-- 3 files changed, 119 insertions(+), 306 deletions(-) diff --git a/examples/plot_kernel_smoothing.py b/examples/plot_kernel_smoothing.py index ace636cfa..2ec162b79 100644 --- a/examples/plot_kernel_smoothing.py +++ b/examples/plot_kernel_smoothing.py @@ -42,17 +42,25 @@ # Local linear regression kernel smoothing. llr = val.optimize_smoothing_parameter( fd, param_values, smoothing_method=ks.LocalLinearRegressionSmoother()) +llr_fd = llr.best_estimator_.transform(fd) + # Nadaraya-Watson kernel smoothing. nw = skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter( fd, param_values, smoothing_method=ks.NadarayaWatsonSmoother()) +nw_fd = nw.best_estimator_.transform(fd) # K-nearest neighbours kernel smoothing. knn = skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter( fd, param_values, smoothing_method=ks.KNeighborsSmoother()) +knn_fd = knn.best_estimator_.transform(fd) + +plt.plot(param_values, knn.cv_results_['mean_test_score']) +plt.plot(param_values, llr.cv_results_['mean_test_score']) +plt.plot(param_values, nw.cv_results_['mean_test_score']) -plt.plot(param_values, knn['scores']) -plt.plot(param_values, llr['scores']) -plt.plot(param_values, nw['scores']) +############################################################################### +# We can plot the smoothed curves corresponding to the 11th element of the data +# set (this is a random choice) for the three different smoothing methods. ax = plt.gca() ax.set_xlabel('Smoothing method parameter') @@ -62,14 +70,10 @@ 'Nadaraya-Watson'], title='Smoothing method') -############################################################################### -# We can plot the smoothed curves corresponding to the 11th element of the data -# set (this is a random choice) for the three different smoothing methods. - fd[10].plot() -knn['fdatagrid'][10].plot() -llr['fdatagrid'][10].plot() -nw['fdatagrid'][10].plot() +knn_fd[10].plot() +llr_fd[10].plot() +nw_fd[10].plot() ax = plt.gca() ax.legend(['original data', 'k-nearest neighbours', 'local linear regression', @@ -85,7 +89,7 @@ # Smoothed plt.figure() fd[10].scatter(s=0.5) -nw['fdatagrid'][10].plot(c='g') +nw_fd[10].plot(c='g') ############################################################################### # Now, we can see the effects of a proper smoothing. We can plot the same 5 @@ -93,7 +97,7 @@ # the best choice of parameter. plt.figure(4) -nw['fdatagrid'][0:5].plot() +nw_fd[0:5].plot() ############################################################################### # We can also appreciate the effects of undersmoothing and oversmoothing in diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 234d0c2ef..2fd0bcbe0 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -23,222 +23,6 @@ __email__ = "miguel.carbajo@estudiante.uam.es" -@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) -def nadaraya_watson(argvals, *, smoothing_parameter=None, - kernel=kernels.normal, weights=None, cv=False): - r"""Nadaraya-Watson smoothing method. - - Provides an smoothing matrix :math:`\hat{H}` for the discretisation - points in argvals by the Nadaraya-Watson estimator. The smoothed - values :math:`\hat{Y}` can be calculated as :math:`\hat{ - Y} = \hat{H}Y` where :math:`Y` is the vector of observations at the - points of discretisation :math:`(x_1, x_2, ..., x_n)`. - - .. math:: - \hat{H}_{i,j} = \frac{K\left(\frac{x_i-x_j}{h}\right)}{\sum_{k=1}^{ - n}K\left( - \frac{x_1-x_k}{h}\right)} - - where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel - window width or smoothing parameter. - - Args: - argvals (ndarray): Vector of discretisation points. - smoothing_parameter (float, optional): Window width of the kernel. - kernel (function, optional): kernel function. By default a normal - kernel. - weights (ndarray, optional): Case weights matrix (in order to modify - the importance of each point). - cv (bool, optional): Flag for cross-validation methods. - Defaults to False. - h (float, optional): same as smoothing_parameter. - bandwidth (float, optional): same as smoothing_parameter. - - Examples: - >>> nadaraya_watson(np.array([1,2,4,5,7]), - ... smoothing_parameter=3.5).round(3) - array([[ 0.294, 0.282, 0.204, 0.153, 0.068], - [ 0.249, 0.259, 0.22 , 0.179, 0.093], - [ 0.165, 0.202, 0.238, 0.229, 0.165], - [ 0.129, 0.172, 0.239, 0.249, 0.211], - [ 0.073, 0.115, 0.221, 0.271, 0.319]]) - >>> nadaraya_watson(np.array([1,2,4,5,7]), h=2).round(3) - array([[ 0.425, 0.375, 0.138, 0.058, 0.005], - [ 0.309, 0.35 , 0.212, 0.114, 0.015], - [ 0.103, 0.193, 0.319, 0.281, 0.103], - [ 0.046, 0.11 , 0.299, 0.339, 0.206], - [ 0.006, 0.022, 0.163, 0.305, 0.503]]) - - Returns: - ndarray: Smoothing matrix :math:`\hat{H}`. - - """ - delta_x = np.abs(np.subtract.outer(argvals, argvals)) - if smoothing_parameter is None: - smoothing_parameter = np.percentile(delta_x, 15) - if cv: - np.fill_diagonal(delta_x, math.inf) - delta_x = delta_x / smoothing_parameter - k = kernel(delta_x) - if weights is not None: - k = k * weights - rs = np.sum(k, 1) - rs[rs == 0] = 1 - return (k.T / rs).T - - -@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) -def local_linear_regression(argvals, smoothing_parameter, *, - kernel=kernels.normal, weights=None, - cv=False): - r"""Local linear regression smoothing method. - - Provides an smoothing matrix :math:`\hat{H}` for the discretisation - points in argvals by the local linear regression estimator. The smoothed - values :math:`\hat{Y}` can be calculated as :math:`\hat{ - Y} = \hat{H}Y` where :math:`Y` is the vector of observations at the points - of discretisation :math:`(x_1, x_2, ..., x_n)`. - - .. math:: - \hat{H}_{i,j} = \frac{b_i(x_j)}{\sum_{k=1}^{n}b_k(x_j)} - - .. math:: - b_i(x) = K\left(\frac{x_i - x}{h}\right) S_{n,2}(x) - (x_i - x)S_{n, - 1}(x) - - .. math:: - S_{n,k} = \sum_{i=1}^{n}K\left(\frac{x_i-x}{h}\right)(x_i-x)^k - - where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel - window width. - - Args: - argvals (ndarray): Vector of discretisation points. - smoothing_parameter (float, optional): Window width of the kernel. - kernel (function, optional): kernel function. By default a normal - kernel. - weights (ndarray, optional): Case weights matrix (in order to modify - the importance of each point). - cv (bool, optional): Flag for cross-validation methods. - Defaults to False. - h (float, optional): same as smoothing_parameter. - bandwidth (float, optional): same as smoothing_parameter. - - Examples: - >>> local_linear_regression(np.array([1,2,4,5,7]), 3.5).round(3) - array([[ 0.614, 0.429, 0.077, -0.03 , -0.09 ], - [ 0.381, 0.595, 0.168, -0. , -0.143], - [-0.104, 0.112, 0.697, 0.398, -0.104], - [-0.147, -0.036, 0.392, 0.639, 0.152], - [-0.095, -0.079, 0.117, 0.308, 0.75 ]]) - >>> local_linear_regression(np.array([1,2,4,5,7]), 2).round(3) - array([[ 0.714, 0.386, -0.037, -0.053, -0.01 ], - [ 0.352, 0.724, 0.045, -0.081, -0.04 ], - [-0.078, 0.052, 0.74 , 0.364, -0.078], - [-0.07 , -0.067, 0.36 , 0.716, 0.061], - [-0.012, -0.032, -0.025, 0.154, 0.915]]) - - - Returns: - ndarray: Smoothing matrix :math:`\hat{H}`. - - """ - delta_x = np.abs(np.subtract.outer(argvals, argvals)) # x_i - x_j - if cv: - np.fill_diagonal(delta_x, math.inf) - k = kernel(delta_x / smoothing_parameter) # K(x_i - x/ h) - s1 = np.sum(k * delta_x, 1) # S_n_1 - s2 = np.sum(k * delta_x ** 2, 1) # S_n_2 - b = (k * (s2 - delta_x * s1)).T # b_i(x_j) - if cv: - np.fill_diagonal(b, 0) - if weights is not None: - b = b * weights - rs = np.sum(b, 1) # sum_{k=1}^{n}b_k(x_j) - return (b.T / rs).T # \\hat{H} - - -@parameter_aliases(smoothing_parameter=['k', 'n_neighbors']) -def knn(argvals, *, smoothing_parameter=None, kernel=kernels.uniform, - weights=None, cv=False): - """K-nearest neighbour kernel smoother. - - Provides an smoothing matrix S for the discretisation points in argvals by - the k nearest neighbours estimator. - - Usually used with the uniform kernel, it takes the average of the closest k - points to a given point. - - - - Args: - argvals (ndarray): Vector of discretisation points. - smoothing_parameter (int, optional): Number of nearest neighbours. By - default it takes the 5% closest points. - kernel (function, optional): kernel function. By default a uniform - kernel to perform a 'usual' k nearest neighbours estimation. - weights (ndarray, optional): Case weights matrix (in order to modify - the importance of each point). - cv (bool, optional): Flag for cross-validation methods. - Defaults to False. - k (float, optional): same as smoothing_parameter. - n_neighbors (float, optional): same as smoothing_parameter. - - Returns: - ndarray: Smoothing matrix. - - Examples: - >>> knn(np.array([1,2,4,5,7]), smoothing_parameter=2) - array([[ 0.5, 0.5, 0. , 0. , 0. ], - [ 0.5, 0.5, 0. , 0. , 0. ], - [ 0. , 0. , 0.5, 0.5, 0. ], - [ 0. , 0. , 0.5, 0.5, 0. ], - [ 0. , 0. , 0. , 0.5, 0.5]]) - - In case there are two points at the same distance it will take both. - - >>> knn(np.array([1,2,3,5,7]), k=2).round(3) - array([[ 0.5 , 0.5 , 0. , 0. , 0. ], - [ 0.333, 0.333, 0.333, 0. , 0. ], - [ 0. , 0.5 , 0.5 , 0. , 0. ], - [ 0. , 0. , 0.333, 0.333, 0.333], - [ 0. , 0. , 0. , 0.5 , 0.5 ]]) - - """ - # Distances matrix of points in argvals - delta_x = np.abs(np.subtract.outer(argvals, argvals)) - - if smoothing_parameter is None: - smoothing_parameter = np.floor(np.percentile( - range(1, len(argvals)), 5)) - elif smoothing_parameter <= 0: - raise ValueError('h must be greater than 0') - if cv: - np.fill_diagonal(delta_x, math.inf) - - # Tolerance to avoid points landing outside the kernel window due to - # computation error - tol = 1 * 10 ** -19 - - # For each row in the distances matrix, it calculates the furthest point - # within the k nearest neighbours - vec = np.percentile(delta_x, smoothing_parameter / len(argvals) * 100, - axis=0, interpolation='lower') + tol - - rr = kernel((delta_x.T / vec).T) - # Applies the kernel to the result of dividing each row by the result - # of the previous operation, all the discretisation points corresponding - # to the knn are below 1 and the rest above 1 so the kernel returns values - # distinct to 0 only for the knn. - - if weights is not None: - rr = (rr.T * weights).T - - # normalise every row - rs = np.sum(rr, 1) - return (rr.T / rs).T - - def _check_r_to_r(f): if f.ndim_domain != 1 or f.ndim_codomain != 1: raise NotImplementedError("Only accepts functions from R to R") @@ -267,7 +51,12 @@ def fit(self, X: FDataGrid, y=None): self.input_points_ = X.sample_points[0] - self.hat_matrix_ = self._hat_matrix_function() + self.hat_matrix_ = self._hat_matrix_function( + input_points=self.input_points_, + smoothing_parameter=self.smoothing_parameter, + kernel=self.kernel, + weights=self.weights + ) return self @@ -278,7 +67,6 @@ def transform(self, X: FDataGrid, y=None): return X.copy(data_matrix=self.hat_matrix_ @ X.data_matrix) -@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) class NadarayaWatsonSmoother(_LinearKernelSmoother): r"""Nadaraya-Watson smoothing method. @@ -298,13 +86,12 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): Args: argvals (ndarray): Vector of discretisation points. - smoothing_parameter (float, optional): Window width of the kernel. + smoothing_parameter (float, optional): Window width of the kernel + (also called h or bandwidth). kernel (function, optional): kernel function. By default a normal kernel. weights (ndarray, optional): Case weights matrix (in order to modify the importance of each point). - h (float, optional): same as smoothing_parameter. - bandwidth (float, optional): same as smoothing_parameter. Examples: >>> smoother = NadarayaWatsonSmoother(smoothing_parameter=3.5) @@ -316,7 +103,7 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 0.165, 0.202, 0.238, 0.229, 0.165], [ 0.129, 0.172, 0.239, 0.249, 0.211], [ 0.073, 0.115, 0.221, 0.271, 0.319]]) - >>> smoother = NadarayaWatsonSmoother(h=2) + >>> smoother = NadarayaWatsonSmoother(smoothing_parameter=2) >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], ... data_matrix=[[0,0,0,0,0]])) >>> smoother.hat_matrix_.round(3) @@ -327,17 +114,22 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 0.006, 0.022, 0.163, 0.305, 0.503]]) """ - def _hat_matrix_function(self): - return nadaraya_watson( - self.input_points_, - smoothing_parameter=self.smoothing_parameter, - kernel=self.kernel, - weights=self.weights) - - return self + def _hat_matrix_function(self, *, input_points, smoothing_parameter, + kernel, weights, _cv=False): + delta_x = np.abs(np.subtract.outer(input_points, input_points)) + if smoothing_parameter is None: + smoothing_parameter = np.percentile(delta_x, 15) + if _cv: + np.fill_diagonal(delta_x, math.inf) + delta_x = delta_x / smoothing_parameter + k = kernel(delta_x) + if weights is not None: + k = k * weights + rs = np.sum(k, 1) + rs[rs == 0] = 1 + return (k.T / rs).T -@parameter_aliases(smoothing_parameter=['h', 'bandwidth']) class LocalLinearRegressionSmoother(_LinearKernelSmoother): r"""Local linear regression smoothing method. @@ -362,13 +154,12 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): Args: argvals (ndarray): Vector of discretisation points. - smoothing_parameter (float, optional): Window width of the kernel. + smoothing_parameter (float, optional): Window width of the kernel + (also called h or bandwidth). kernel (function, optional): kernel function. By default a normal kernel. weights (ndarray, optional): Case weights matrix (in order to modify the importance of each point). - h (float, optional): same as smoothing_parameter. - bandwidth (float, optional): same as smoothing_parameter. Examples: >>> smoother = LocalLinearRegressionSmoother(smoothing_parameter=3.5) @@ -380,7 +171,7 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): [-0.104, 0.112, 0.697, 0.398, -0.104], [-0.147, -0.036, 0.392, 0.639, 0.152], [-0.095, -0.079, 0.117, 0.308, 0.75 ]]) - >>> smoother = LocalLinearRegressionSmoother(bandwidth=2) + >>> smoother = LocalLinearRegressionSmoother(smoothing_parameter=2) >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], ... data_matrix=[[0,0,0,0,0]])) >>> smoother.hat_matrix_.round(3) @@ -392,17 +183,24 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): """ - def _hat_matrix_function(self): - return local_linear_regression( - self.input_points_, - smoothing_parameter=self.smoothing_parameter, - kernel=self.kernel, - weights=self.weights) - - return self + def _hat_matrix_function(self, *, input_points, smoothing_parameter, + kernel, weights, _cv=False): + delta_x = np.abs(np.subtract.outer(input_points, + input_points)) # x_i - x_j + if _cv: + np.fill_diagonal(delta_x, math.inf) + k = kernel(delta_x / smoothing_parameter) # K(x_i - x/ h) + s1 = np.sum(k * delta_x, 1) # S_n_1 + s2 = np.sum(k * delta_x ** 2, 1) # S_n_2 + b = (k * (s2 - delta_x * s1)).T # b_i(x_j) + if _cv: + np.fill_diagonal(b, 0) + if weights is not None: + b = b * weights + rs = np.sum(b, 1) # sum_{k=1}^{n}b_k(x_j) + return (b.T / rs).T # \\hat{H} -@parameter_aliases(smoothing_parameter=['k', 'n_neighbors']) class KNeighborsSmoother(_LinearKernelSmoother): """K-nearest neighbour kernel smoother. @@ -420,10 +218,6 @@ class KNeighborsSmoother(_LinearKernelSmoother): kernel to perform a 'usual' k nearest neighbours estimation. weights (ndarray, optional): Case weights matrix (in order to modify the importance of each point). - cv (bool, optional): Flag for cross-validation methods. - Defaults to False. - k (float, optional): same as smoothing_parameter. - n_neighbors (float, optional): same as smoothing_parameter. Examples: >>> smoother = KNeighborsSmoother(smoothing_parameter=2) @@ -451,15 +245,44 @@ class KNeighborsSmoother(_LinearKernelSmoother): """ def __init__(self, *, smoothing_parameter=None, kernel=kernels.uniform, weights=None): - self.smoothing_parameter = smoothing_parameter - self.kernel = kernel - self.weights = weights - - def _hat_matrix_function(self): - return knn( - self.input_points_, - smoothing_parameter=self.smoothing_parameter, - kernel=self.kernel, - weights=self.weights) - - return self + super().__init__( + smoothing_parameter=smoothing_parameter, + kernel=kernel, + weights=weights + ) + + def _hat_matrix_function(self, *, input_points, smoothing_parameter, + kernel, weights, _cv=False): + # Distances matrix of points in argvals + delta_x = np.abs(np.subtract.outer(input_points, input_points)) + + if smoothing_parameter is None: + smoothing_parameter = np.floor(np.percentile( + range(1, len(input_points)), 5)) + elif smoothing_parameter <= 0: + raise ValueError('h must be greater than 0') + if _cv: + np.fill_diagonal(delta_x, math.inf) + + # Tolerance to avoid points landing outside the kernel window due to + # computation error + tol = 1.0e-19 + + # For each row in the distances matrix, it calculates the furthest + # point within the k nearest neighbours + vec = np.percentile(delta_x, smoothing_parameter + / len(input_points) * 100, + axis=0, interpolation='lower') + tol + + rr = kernel((delta_x.T / vec).T) + # Applies the kernel to the result of dividing each row by the result + # of the previous operation, all the discretisation points + # corresponding to the knn are below 1 and the rest above 1 so the + # kernel returns values distinct to 0 only for the knn. + + if weights is not None: + rr = (rr.T * weights).T + + # normalise every row + rs = np.sum(rr, 1) + return (rr.T / rs).T diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index d1eaa2163..302182393 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -120,23 +120,7 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, penalization function can be specified through this parameter. Returns: - dict: A dictionary containing the following: - - { - 'scores': (list of double) List of the scores for each - parameter. - - 'best_score': (double) Minimum score. - - 'best_parameter': (double) Parameter that produces the - lesser score. - - 'hat_matrix': (numpy.darray) Hat matrix built with the best - parameter. - - 'fdatagrid': (FDataGrid) Smoothed FDataGrid object. - - } + grid: A scikit-learn GridSearchCV estimator, properly fitted. Examples: Creates a FDataGrid object of the function :math:`y=x^2` and peforms @@ -145,21 +129,21 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, >>> import skfda >>> x = np.linspace(-2, 2, 5) >>> fd = skfda.FDataGrid(x ** 2, x) - >>> res = optimize_smoothing_parameter(fd, [2,3], - ... smoothing_method=kernel_smoothers.KNeighborsSmoother()) - >>> np.array(res['scores']).round(2) + >>> grid = optimize_smoothing_parameter(fd, [2,3], + ... smoothing_method=kernel_smoothers.KNeighborsSmoother()) + >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-11.67, -12.37]) - >>> round(res['best_score'], 2) + >>> round(grid.best_score_, 2) -11.67 - >>> res['best_parameter'] + >>> grid.best_params_['smoothing_parameter'] 2 - >>> res['hat_matrix'].round(2) + >>> grid.best_estimator_.hat_matrix_.round(2) array([[ 0.5 , 0.5 , 0. , 0. , 0. ], [ 0.33, 0.33, 0.33, 0. , 0. ], [ 0. , 0.33, 0.33, 0.33, 0. ], [ 0. , 0. , 0.33, 0.33, 0.33], [ 0. , 0. , 0. , 0.5 , 0.5 ]]) - >>> res['fdatagrid'].round(2) + >>> grid.best_estimator_.transform(fd).round(2) FDataGrid( array([[[ 2.5 ], [ 1.67], @@ -173,32 +157,32 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, Other validation methods can be used such as cross-validation or general cross validation using other penalization functions. - >>> res = optimize_smoothing_parameter(fd, [2,3], + >>> grid = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherLeaveOneOutScorer()) - >>> np.array(res['scores']).round(2) + >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-4.2, -5.5]) - >>> res = optimize_smoothing_parameter(fd, [2,3], + >>> grid = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherGeneralizedCVScorer( ... akaike_information_criterion)) - >>> np.array(res['scores']).round(2) + >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([ -9.35, -10.71]) - >>> res = optimize_smoothing_parameter(fd, [2,3], + >>> grid = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherGeneralizedCVScorer( ... finite_prediction_error)) - >>> np.array(res['scores']).round(2) + >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([ -9.8, -11. ]) - >>> res = optimize_smoothing_parameter(fd, [2,3], + >>> grid = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherGeneralizedCVScorer(shibata)) - >>> np.array(res['scores']).round(2) + >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-7.56, -9.17]) - >>> res = optimize_smoothing_parameter(fd, [2,3], + >>> grid = optimize_smoothing_parameter(fd, [2,3], ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), ... cv_method=LinearSmootherGeneralizedCVScorer(rice)) - >>> np.array(res['scores']).round(2) + >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-21. , -16.5]) """ @@ -221,6 +205,8 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, param_grid={'smoothing_parameter': parameter_values}, scoring=cv_method, cv=[(slice(None), slice(None))]) grid.fit(fdatagrid, fdatagrid) + + return grid scores = grid.cv_results_['mean_test_score'] best_score = grid.best_score_ best_parameter = grid.best_params_['smoothing_parameter'] From 47006c88ddc66c690f44d5f424992f8d8979a921 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 14 Jun 2019 20:31:39 +0200 Subject: [PATCH 078/222] Final coverage and pep 8 only in one os test --- .travis.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index b31c8a0bc..4c9a98148 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,6 +6,9 @@ matrix: - name: "Python 3.7.1 on Xenial Linux" python: 3.7 # this works for Linux but is ignored on macOS or Windows dist: xenial # required for Python >= 3.7 + env: + - COVERAGE=true # coverage test are only run in python 3.7 + - PEP8=true # pep8 checks are only run in python 3.7 - name: "Python 3.7.2 on macOS" os: osx osx_image: xcode10.2 # Python 3.7.2 running on macOS 10.14.3 @@ -15,14 +18,26 @@ matrix: language: shell # 'language: python' is an error on Travis CI Windows before_install: choco install python env: PATH=/c/Python37:/c/Python37/Scripts:$PATH -install: pip3 install --upgrade pip cython numpy flake8 codecov || pip3 install --upgrade --user pip cython numpy flake8 codecov # all three OSes agree about 'pip3' +install: + - pip3 install --upgrade pip cython numpy || pip3 install --upgrade --user pip cython numpy # all three OSes agree about 'pip3' + - pip3 install flake8 || pip3 install --user flake8 + - pip3 install codecov || pip3 install --user codecov # 'python' points to Python 2.7 on macOS but points to Python 3.7 on Linux and Windows # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only script: - python3 setup.py test || python setup.py test - - flake8 --exit-zero skfda - - coverage run setup.py test + - | + if [[ $PEP8 == true ]]; then + flake8 --exit-zero skfda + fi + - | + if [[ $COVERAGE == true ]]; then + coverage run setup.py test + fi after_success: - - codecov \ No newline at end of file + - | + if [[ $COVERAGE == true ]]; then + codecov + fi From 9b74c123db2954594a90c8b9dbe3897d20698d72 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 09:59:51 +0200 Subject: [PATCH 079/222] Omit test on coverage --- .coveragerc | 2 ++ .travis.yml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..c712d2595 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = tests/* diff --git a/.travis.yml b/.travis.yml index 4c9a98148..1bfd3633d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,7 +33,7 @@ script: fi - | if [[ $COVERAGE == true ]]; then - coverage run setup.py test + pytest --cov-config=.coveragerc --cov=skfda fi after_success: From 5fc4226fe02e69d250eeacebc2ff21e081d0a1e0 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 10:07:20 +0200 Subject: [PATCH 080/222] Installing dependencies for coverage --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 1bfd3633d..e31428c2c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,7 +21,7 @@ matrix: install: - pip3 install --upgrade pip cython numpy || pip3 install --upgrade --user pip cython numpy # all three OSes agree about 'pip3' - pip3 install flake8 || pip3 install --user flake8 - - pip3 install codecov || pip3 install --user codecov + - pip3 install codecov pytest-cov || pip3 install --user codecov pytest-cov # 'python' points to Python 2.7 on macOS but points to Python 3.7 on Linux and Windows # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only From 85d5c0ba0cbbf1501b1aea2c6b7411b16c324cc3 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 10:12:11 +0200 Subject: [PATCH 081/222] Added the requirments --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index e31428c2c..9cb483193 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,6 +33,7 @@ script: fi - | if [[ $COVERAGE == true ]]; then + pip3 install -r requirements.txt pytest --cov-config=.coveragerc --cov=skfda fi From 9556946db4d4e0d8c9216854e4e92490eb9fb51f Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 10:17:40 +0200 Subject: [PATCH 082/222] New config --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 9cb483193..eed624152 100644 --- a/.travis.yml +++ b/.travis.yml @@ -34,6 +34,7 @@ script: - | if [[ $COVERAGE == true ]]; then pip3 install -r requirements.txt + python setup.py install pytest --cov-config=.coveragerc --cov=skfda fi From 2e0de6a2b4c0dcecee606878539e33e412221118 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 10:22:37 +0200 Subject: [PATCH 083/222] Another try --- .travis.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index eed624152..650a02e5f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -33,9 +33,10 @@ script: fi - | if [[ $COVERAGE == true ]]; then - pip3 install -r requirements.txt - python setup.py install - pytest --cov-config=.coveragerc --cov=skfda + coverage run --source=skfda/ setup.py test +# pip3 install -r requirements.txt +# python setup.py install +# pytest --cov-config=.coveragerc --cov=skfda fi after_success: From 33de7a542a2fab32b7ae527559a853a17438f2dc Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 10:25:01 +0200 Subject: [PATCH 084/222] Fixed travis error --- .travis.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 650a02e5f..ee24620e7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -34,9 +34,6 @@ script: - | if [[ $COVERAGE == true ]]; then coverage run --source=skfda/ setup.py test -# pip3 install -r requirements.txt -# python setup.py install -# pytest --cov-config=.coveragerc --cov=skfda fi after_success: @@ -44,3 +41,7 @@ after_success: if [[ $COVERAGE == true ]]; then codecov fi + +# pip3 install -r requirements.txt +# python setup.py install +# pytest --cov-config=.coveragerc --cov=skfda \ No newline at end of file From 5b2d64c12263d38900fb5b33b39c9196cad7ce6a Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 10:29:27 +0200 Subject: [PATCH 085/222] Remove the coverage config file --- .coveragerc | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index c712d2595..000000000 --- a/.coveragerc +++ /dev/null @@ -1,2 +0,0 @@ -[run] -omit = tests/* From ae3d2dc774102d719bdd09581abb3c191424c1b0 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 15 Jun 2019 10:30:11 +0200 Subject: [PATCH 086/222] Some comments cleaned --- .travis.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index ee24620e7..62d45b53c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -42,6 +42,3 @@ after_success: codecov fi -# pip3 install -r requirements.txt -# python setup.py install -# pytest --cov-config=.coveragerc --cov=skfda \ No newline at end of file From bbeee4ddabde501913d783e787bb81ee754686bf Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 16 Jun 2019 15:35:44 +0200 Subject: [PATCH 087/222] README updated --- README.rst | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 24bc67654..2826b5fbc 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,14 @@ scikit-fda ========== -|build-status| |docs| +|build-status| |docs| |Codecov|_ |PyPi|_ + +.. |Codecov| image:: https://codecov.io/gh/GAA-UAM/scikit-fda/branch/develop/graph/badge.svg +.. _Codecov: https://codecov.io/github/scikit-fda/scikit-fda?branch=develop + +.. |PyPi| image:: https://badge.fury.io/py/scikit-fda.svg +.. _PyPi: https://badge.fury.io/py/scikit-fda + Functional Data Analysis is the field of Statistics that analyses data that come in the shape of functions. To know more about fda have a look at fda_ or read [RS05]_. From a044e8f715012f967b318498657a7f027d69e893 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Wed, 19 Jun 2019 08:03:20 +0200 Subject: [PATCH 088/222] Distinct test for coverage --- .travis.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 62d45b53c..1fa4746b0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,11 @@ matrix: language: shell # 'language: python' is an error on Travis CI Windows before_install: choco install python env: PATH=/c/Python37:/c/Python37/Scripts:$PATH + - name: "Coverage and pep 8 tests on Python 3.7.1 on Xenial Linux" + python: 3.7 # this works for Linux but is ignored on macOS or Windows + dist: xenial # required for Python >= 3.7 + env: + - PEP8COVERAGE=true # coverage test are only install: - pip3 install --upgrade pip cython numpy || pip3 install --upgrade --user pip cython numpy # all three OSes agree about 'pip3' - pip3 install flake8 || pip3 install --user flake8 @@ -26,19 +31,17 @@ install: # 'python' points to Python 2.7 on macOS but points to Python 3.7 on Linux and Windows # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only script: - - python3 setup.py test || python setup.py test - | - if [[ $PEP8 == true ]]; then + if [[ $PEP8COVERAGE == false ]]; then + python3 setup.py test || python setup.py test + else flake8 --exit-zero skfda + coverage run --source=skfda/ setup.py test; fi - - | - if [[ $COVERAGE == true ]]; then - coverage run --source=skfda/ setup.py test - fi + after_success: - | - if [[ $COVERAGE == true ]]; then + if [[ $PEP8COVERAGE == true ]]; then codecov fi - From e1247ff055df1a094b1d144756d44b9dcf1a9159 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Wed, 19 Jun 2019 08:09:30 +0200 Subject: [PATCH 089/222] New config test --- .travis.yml | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1fa4746b0..8b445a1df 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,18 +25,21 @@ matrix: - PEP8COVERAGE=true # coverage test are only install: - pip3 install --upgrade pip cython numpy || pip3 install --upgrade --user pip cython numpy # all three OSes agree about 'pip3' - - pip3 install flake8 || pip3 install --user flake8 - - pip3 install codecov pytest-cov || pip3 install --user codecov pytest-cov + - | + if [[ $PEP8COVERAGE == true ]]; then + pip3 install flake8 || pip3 install --user flake8 + pip3 install codecov pytest-cov || pip3 install --user codecov pytest-cov + fi # 'python' points to Python 2.7 on macOS but points to Python 3.7 on Linux and Windows # 'python3' is a 'command not found' error on Windows but 'py' works on Windows only script: - | - if [[ $PEP8COVERAGE == false ]]; then - python3 setup.py test || python setup.py test - else - flake8 --exit-zero skfda + if [[ $PEP8COVERAGE == true ]]; then + flake8 --exit-zero skfda; coverage run --source=skfda/ setup.py test; + else + python3 setup.py test || python setup.py test; fi From 054690ae84aa7e8051f0a42e5aa025062c6188ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Wed, 19 Jun 2019 13:34:18 +0200 Subject: [PATCH 090/222] Correct codecov badge link --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2826b5fbc..8f4303130 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ scikit-fda |build-status| |docs| |Codecov|_ |PyPi|_ .. |Codecov| image:: https://codecov.io/gh/GAA-UAM/scikit-fda/branch/develop/graph/badge.svg -.. _Codecov: https://codecov.io/github/scikit-fda/scikit-fda?branch=develop +.. _Codecov: https://codecov.io/github/GAA-UAM/scikit-fda?branch=develop .. |PyPi| image:: https://badge.fury.io/py/scikit-fda.svg .. _PyPi: https://badge.fury.io/py/scikit-fda From 3b9d95e7210ce15386b55932d260382002167f20 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 20 Jun 2019 16:47:55 +0200 Subject: [PATCH 091/222] Update the docs --- docs/modules/preprocessing.rst | 1 + docs/modules/preprocessing/smoothing.rst | 74 +++++++++++++++++++++ examples/plot_kernel_smoothing.py | 18 +++-- skfda/preprocessing/smoothing/validation.py | 15 ++--- 4 files changed, 90 insertions(+), 18 deletions(-) create mode 100644 docs/modules/preprocessing/smoothing.rst diff --git a/docs/modules/preprocessing.rst b/docs/modules/preprocessing.rst index 3ca68484d..61c02cca7 100644 --- a/docs/modules/preprocessing.rst +++ b/docs/modules/preprocessing.rst @@ -8,4 +8,5 @@ this category deal with this problem. :maxdepth: 4 :caption: Modules: + preprocessing/smoothing preprocessing/registration diff --git a/docs/modules/preprocessing/smoothing.rst b/docs/modules/preprocessing/smoothing.rst new file mode 100644 index 000000000..35e3b8992 --- /dev/null +++ b/docs/modules/preprocessing/smoothing.rst @@ -0,0 +1,74 @@ +Smoothing +========= + +Sometimes the functional observations are noisy. The noise can be reduced +by smoothing the data. + +Kernel smoothers +---------------- + +Kernel smoothing methods compute the smoothed value at a point by considering +the influence of each input point over it. For doing this, it considers a +kernel function placed at the desired point. The influence of each input point +will be related with the value of the kernel function at that input point. + +There are several kernel smoothers provided in this library. All of them are +also *linear* smoothers, meaning that they compute a smoothing matrix (or hat +matrix) that performs the smoothing as a linear transformation. + +All of the smoothers follow the API of an scikit-learn transformer object. + +The degree of smoothing is controlled in all smoother by an +*smoothing parameter*, named ``smoothing_parameter``, that has different +meaning for each smoother. + +.. autosummary:: + :toctree: autosummary + + skfda.preprocessing.smoothing.kernel_smoothers.NadarayaWatsonSmoother + skfda.preprocessing.smoothing.kernel_smoothers.LocalLinearRegressionSmoother + skfda.preprocessing.smoothing.kernel_smoothers.KNeighborsSmoother + +Validation +---------- + +It is necessary to measure how good is the smoothing to prevent +*undersmoothing* and *oversmoothing*. The following classes follow the +scikit-learn API for a scorer object, and measure how good is the smoothing. +In both of them, the target object ``y`` should also be the original data. +These scorers need that the smoother is linear, as they use internally the +hat matrix. + +.. autosummary:: + :toctree: autosummary + + skfda.preprocessing.smoothing.validation.LinearSmootherLeaveOneOutScorer + skfda.preprocessing.smoothing.validation.LinearSmootherGeneralizedCVScorer + +The `LinearSmootherGeneralizedCVScorer` object accepts also an optional +penalization_function, used instead of the default one. The available ones +are: + +.. autosummary:: + :toctree: autosummary + + skfda.preprocessing.smoothing.validation.akaike_information_criterion + skfda.preprocessing.smoothing.validation.finite_prediction_error + skfda.preprocessing.smoothing.validation.shibata + skfda.preprocessing.smoothing.validation.rice + +An utility method is also provided, which calls the sckit-learn `GridSearchCV` +object with the scorers to find the best smoothing parameters from a list. + +.. autosummary:: + :toctree: autosummary + + skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter + + +References +---------- + +* Ramsay, J., Silverman, B. W. (2005). Functional Data Analysis. Springer. + +* Wasserman, L. (2006). All of nonparametric statistics. Springer Science & Business Media. diff --git a/examples/plot_kernel_smoothing.py b/examples/plot_kernel_smoothing.py index 2ec162b79..d84c91af8 100644 --- a/examples/plot_kernel_smoothing.py +++ b/examples/plot_kernel_smoothing.py @@ -54,9 +54,13 @@ fd, param_values, smoothing_method=ks.KNeighborsSmoother()) knn_fd = knn.best_estimator_.transform(fd) -plt.plot(param_values, knn.cv_results_['mean_test_score']) -plt.plot(param_values, llr.cv_results_['mean_test_score']) -plt.plot(param_values, nw.cv_results_['mean_test_score']) +plt.plot(param_values, knn.cv_results_['mean_test_score'], + label='k-nearest neighbors') +plt.plot(param_values, llr.cv_results_['mean_test_score'], + label='local linear regression') +plt.plot(param_values, nw.cv_results_['mean_test_score'], + label='Nadaraya-Watson') +plt.legend() ############################################################################### # We can plot the smoothed curves corresponding to the 11th element of the data @@ -66,7 +70,7 @@ ax.set_xlabel('Smoothing method parameter') ax.set_ylabel('GCV score') ax.set_title('Scores through GCV for different smoothing methods') -ax.legend(['k-nearest neighbours', 'local linear regression', +ax.legend(['k-nearest neighbors', 'local linear regression', 'Nadaraya-Watson'], title='Smoothing method') @@ -75,7 +79,7 @@ llr_fd[10].plot() nw_fd[10].plot() ax = plt.gca() -ax.legend(['original data', 'k-nearest neighbours', +ax.legend(['original data', 'k-nearest neighbors', 'local linear regression', 'Nadaraya-Watson'], title='Smoothing method') @@ -103,8 +107,8 @@ # We can also appreciate the effects of undersmoothing and oversmoothing in # the following plots. -fd_us = ks.NadarayaWatsonSmoother(h=2).fit_transform(fd[10]) -fd_os = ks.NadarayaWatsonSmoother(h=15).fit_transform(fd[10]) +fd_us = ks.NadarayaWatsonSmoother(smoothing_parameter=2).fit_transform(fd[10]) +fd_os = ks.NadarayaWatsonSmoother(smoothing_parameter=15).fit_transform(fd[10]) # Under-smoothed fd[10].scatter(s=0.5) diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 302182393..92d8f935b 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -107,6 +107,10 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, Performs the smoothing of a FDataGrid object choosing the best parameter of a given list using a cross validation scoring method. + Note: + This is similar to fitting a scikit-learn GridSearchCV over the + data, using the cv_method as a scorer. + Args: fdatagrid (FDataGrid): FDataGrid object. parameters (list of double): List of parameters to be tested. @@ -207,17 +211,6 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, grid.fit(fdatagrid, fdatagrid) return grid - scores = grid.cv_results_['mean_test_score'] - best_score = grid.best_score_ - best_parameter = grid.best_params_['smoothing_parameter'] - best_estimator = grid.best_estimator_ - - return {'scores': scores, - 'best_score': best_score, - 'best_parameter': best_parameter, - 'hat_matrix': best_estimator.hat_matrix_, - 'fdatagrid': best_estimator.transform(fdatagrid) - } def akaike_information_criterion(hat_matrix): From a44e927cfd7d14c6c363ac08f26a3a7fd35bc82e Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Thu, 20 Jun 2019 20:44:08 +0200 Subject: [PATCH 092/222] Fixed PEP8 on datasets --- skfda/__init__.py | 6 +- skfda/datasets/_samples_generators.py | 80 +++++++++++++-------------- 2 files changed, 42 insertions(+), 44 deletions(-) diff --git a/skfda/__init__.py b/skfda/__init__.py index 2e5e1b680..e00b6c308 100644 --- a/skfda/__init__.py +++ b/skfda/__init__.py @@ -10,13 +10,15 @@ best smooths a FDataGrid object. - depth_measures: depth methods to order he samples of FDataGrid objects. - magnitude-shape plot: visualization tool. - - fdata_boxplot: informative exploratory tool for visualizing functional data. + - fdata_boxplot: informative exploratory tool for visualizing functional + data. - clustering: K-Means and Fuzzy-KMeans algorithms implemented to cluster data in the FDataGrid, along with plotting methods. and the following classes: - FDataGrid: Discrete representation of functional data. - FDataBasis: Basis representation for functional data. - - Boxplot: Implements the functional boxplot for FDataGrid with domain dimension 1. + - Boxplot: Implements the functional boxplot for FDataGrid with domain + dimension 1. - SurfaceBoxplot: Implements the functional boxplot for FDataGrid with domain dimension 2. - MagnitudeShapePlot: Implements the magnitude shape plot for FDataGrid diff --git a/skfda/datasets/_samples_generators.py b/skfda/datasets/_samples_generators.py index cf849c8d3..a74788026 100644 --- a/skfda/datasets/_samples_generators.py +++ b/skfda/datasets/_samples_generators.py @@ -8,9 +8,9 @@ from ..preprocessing.registration import normalize_warping -def make_gaussian_process(n_samples: int=100, n_features: int=100, *, - start: float=0., stop: float=1., - mean=0, cov=None, noise: float=0., +def make_gaussian_process(n_samples: int = 100, n_features: int = 100, *, + start: float = 0., stop: float = 1., + mean=0, cov=None, noise: float = 0., random_state=None): """Generate Gaussian process trajectories. @@ -55,11 +55,12 @@ def make_gaussian_process(n_samples: int=100, n_features: int=100, *, return FDataGrid(sample_points=x, data_matrix=y) -def make_sinusoidal_process(n_samples: int=15, n_features: int=100, *, - start: float=0., stop: float=1., period: float=1., - phase_mean: float=0., phase_std: float=.6, - amplitude_mean: float=1., amplitude_std: float=.05, - error_std: float=.2, random_state=None): +def make_sinusoidal_process(n_samples: int = 15, n_features: int = 100, *, + start: float = 0., stop: float = 1., + period: float = 1., phase_mean: float = 0., + phase_std: float = .6, amplitude_mean: float = 1., + amplitude_std: float = .05, error_std: float = .2, + random_state=None): r"""Generate sinusoidal proccess. @@ -104,23 +105,22 @@ def make_sinusoidal_process(n_samples: int=15, n_features: int=100, *, error = random_state.normal(0, error_std, (n_samples, n_features)) - y = alpha @ np.sin((2*np.pi/period)*t + phi) + error return FDataGrid(sample_points=t, data_matrix=y) -def make_multimodal_landmarks(n_samples: int=15, *, n_modes: int=1, - ndim_domain: int=1, ndim_image: int = 1, - start: float=-1, stop: float=1, std: float=.05, - random_state=None): +def make_multimodal_landmarks(n_samples: int = 15, *, n_modes: int = 1, + ndim_domain: int = 1, ndim_image: int = 1, + start: float = -1, stop: float = 1, + std: float = .05, random_state=None): """Generate landmarks points. Used by :func:`make_multimodal_samples` to generate the location of the landmarks. - Generates a matrix containing the landmarks or locations of the modes of the - samples generates by :func:`make_multimodal_samples`. + Generates a matrix containing the landmarks or locations of the modes + of the samples generates by :func:`make_multimodal_samples`. If the same random state is used when generating the landmarks and multimodal samples, these will correspond to the position of the modes of @@ -140,8 +140,8 @@ def make_multimodal_landmarks(n_samples: int=15, *, n_modes: int=1, Returns: :class:`np.ndarray` with the location of the modes, where the component - (i,j,k) corresponds to the mode k of the image dimension j of the sample - i. + (i,j,k) corresponds to the mode k of the image dimension j of the + sample i. """ random_state = sklearn.utils.check_random_state(random_state) @@ -150,7 +150,6 @@ def make_multimodal_landmarks(n_samples: int=15, *, n_modes: int=1, modes_location = np.repeat(modes_location[:, np.newaxis], ndim_domain, axis=1) - variation = random_state.multivariate_normal((0,) * ndim_domain, std * np.eye(ndim_domain), size=(n_samples, @@ -160,12 +159,12 @@ def make_multimodal_landmarks(n_samples: int=15, *, n_modes: int=1, return modes_location + variation -def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, - points_per_dim: int=100, ndim_domain: int=1, - ndim_image: int=1, start: float=-1, stop: float=1., - std: float=.05, mode_std: float=.02, - noise: float=.0, modes_location=None, - random_state=None): +def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, + points_per_dim: int = 100, ndim_domain: int = 1, + ndim_image: int = 1, start: float = -1, + stop: float = 1., std: float = .05, + mode_std: float = .02, noise: float = .0, + modes_location=None, random_state=None): r"""Generate multimodal samples. @@ -208,7 +207,6 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, random_state = sklearn.utils.check_random_state(random_state) - if modes_location is None: location = make_multimodal_landmarks(n_samples=n_samples, @@ -226,7 +224,6 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, shape = (n_samples, ndim_image, n_modes, ndim_domain) location = location.reshape(shape) - axis = np.linspace(start, stop, points_per_dim) if ndim_domain == 1: @@ -249,12 +246,13 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, # Covariance matrix of the samples cov = mode_std * np.eye(ndim_domain) - for i in range(n_samples): - for j in range(ndim_image): - for k in range(n_modes): - data_matrix[i,...,j] += multivariate_normal.pdf(evaluation_grid, - mean=location[i,j,k], - cov=cov) + import itertools + for i, j, k in itertools.product(range(n_samples), + range(ndim_image), + range(n_modes)): + data_matrix[i, ..., j] += multivariate_normal.pdf(evaluation_grid, + location[i, j, k], + cov) # Constant to make modes value aprox. 1 data_matrix *= (2*np.pi*mode_std)**(ndim_domain/2) @@ -264,9 +262,9 @@ def make_multimodal_samples(n_samples: int=15, *, n_modes: int=1, return FDataGrid(sample_points=sample_points, data_matrix=data_matrix) -def make_random_warping(n_samples: int=15, n_features: int=100, *, - start: float=0., stop: float=1., sigma: float=1., - shape_parameter: float=50, n_random: int=4, +def make_random_warping(n_samples: int = 15, n_features: int = 100, *, + start: float = 0., stop: float = 1., sigma: float = 1., + shape_parameter: float = 50, n_random: int = 4, random_state=None): r"""Generate random warping functions. @@ -309,12 +307,11 @@ def make_random_warping(n_samples: int=15, n_features: int=100, *, :class:`FDataGrid` object comprising all the samples. """ - # Based on the original implementation of J. D. Tucker in the - # package python_fdasrsf . + # Based on the original implementation of J. D. Tucker in the + # package python_fdasrsf . random_state = sklearn.utils.check_random_state(random_state) - freq = shape_parameter + 1 # Frequency @@ -322,14 +319,14 @@ def make_random_warping(n_samples: int=15, n_features: int=100, *, sqrt2 = np.sqrt(2) sqrt_sigma = np.sqrt(sigma) - # Originally it is compute in (0,1), then it is rescaled + # Originally it is compute in (0,1), then it is rescaled time = np.outer(np.linspace(0, 1, n_features), np.ones(n_samples)) # Operates trasposed to broadcast dimensions v = np.outer(np.ones(n_features), random_state.normal(scale=sqrt_sigma, size=n_samples)) - for j in range(2,2+n_random): + for j in range(2, 2+n_random): alpha = random_state.normal(scale=sqrt_sigma, size=(2, n_samples)) alpha *= sqrt2 v += alpha[0] * np.cos(j*omega*time) @@ -349,10 +346,9 @@ def make_random_warping(n_samples: int=15, n_features: int=100, *, # Creation of FDataGrid in the corresponding domain data_matrix = scipy.integrate.cumtrapz(v, dx=1./n_features, initial=0, axis=0) - warping = FDataGrid(data_matrix.T, sample_points=time[:,0]) + warping = FDataGrid(data_matrix.T, sample_points=time[:, 0]) warping = normalize_warping(warping, domain_range=(start, stop)) warping.interpolator = SplineInterpolator(interpolation_order=3, monotone=True) - return warping From cf33d1ae034b04492ec211b2277ab0f95a82a1b3 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Thu, 20 Jun 2019 21:07:04 +0200 Subject: [PATCH 093/222] Fixed PEP8 on preprocessing --- skfda/preprocessing/registration/_elastic.py | 36 +++++++++++-------- .../registration/_landmark_registration.py | 21 ++++++----- .../registration/_registration_utils.py | 17 ++++----- .../registration/_shift_registration.py | 17 +++++---- .../smoothing/kernel_smoothers.py | 2 +- 5 files changed, 53 insertions(+), 40 deletions(-) diff --git a/skfda/preprocessing/registration/_elastic.py b/skfda/preprocessing/registration/_elastic.py index a3f80d938..b43ddd780 100644 --- a/skfda/preprocessing/registration/_elastic.py +++ b/skfda/preprocessing/registration/_elastic.py @@ -145,14 +145,17 @@ def from_srsf(fdatagrid, initial=None, *, eval_points=None): return fdatagrid.copy(data_matrix=f_data_matrix, sample_points=eval_points) -def _elastic_alignment_array(template_data, q_data, eval_points, lam, grid_dim): +def _elastic_alignment_array(template_data, q_data, + eval_points, lam, grid_dim): r"""Wrapper between the cython interface and python. - Selects the corresponding routine depending on the dimensions of the arrays. + Selects the corresponding routine depending on the dimensions of the + arrays. Args: template_data (numpy.ndarray): Array with the srsf of the template. - q_data (numpy.ndarray): Array with the srsf of the curves to be aligned. + q_data (numpy.ndarray): Array with the srsf of the curves + to be aligned. eval_points (numpy.ndarray): Discretisation points of the functions. lam (float): Penalisation term. grid_dim (int): Dimension of the grid used in the alignment algorithm. @@ -215,8 +218,8 @@ def elastic_registration_warping(fdatagrid, template=None, *, lam=0., the local minimum of the sum of squares of elastic distances. See :func:`elastic_mean`. - In [SK16-4-3]_ are described extensively the algorithms employed and the SRSF - framework. + In [SK16-4-3]_ are described extensively the algorithms employed and + the SRSF framework. Args: fdatagrid (:class:`FDataGrid`): Functional data object to be aligned. @@ -224,7 +227,8 @@ def elastic_registration_warping(fdatagrid, template=None, *, lam=0., Can contain 1 sample to align all the curves to it or the same number of samples than the fdatagrid. By default it is used the elastic mean. - lam (float, optional): Controls the amount of elasticity. Defaults to 0. + lam (float, optional): Controls the amount of elasticity. + Defaults to 0. eval_points (array_like, optional): Set of points where the functions are evaluated, by default uses the sample points of the fdatagrid. @@ -260,8 +264,8 @@ def elastic_registration_warping(fdatagrid, template=None, *, lam=0., template = elastic_mean(fdatagrid, lam=lam, eval_points=eval_points, **kwargs) - elif ((template.nsamples != 1 and template.nsamples != fdatagrid.nsamples) or - template.ndim_domain != 1 or template.ndim_image != 1): + elif ((template.nsamples != 1 and template.nsamples != fdatagrid.nsamples) + or template.ndim_domain != 1 or template.ndim_image != 1): raise ValueError("The template should contain one sample to align all" "the curves to the same function or the same number " @@ -332,8 +336,8 @@ def elastic_registration(fdatagrid, template=None, *, lam=0., eval_points=None, the local minimum of the sum of squares of elastic distances. See :func:`elastic_mean`. - In [SK16-4-2]_ are described extensively the algorithms employed and the SRSF - framework. + In [SK16-4-2]_ are described extensively the algorithms employed and + the SRSF framework. Args: fdatagrid (:class:`FDataGrid`): Functional data object to be aligned. @@ -341,7 +345,8 @@ def elastic_registration(fdatagrid, template=None, *, lam=0., eval_points=None, Can contain 1 sample to align all the curves to it or the same number of samples than the fdatagrid. By default it is used the elastic mean. - lam (float, optional): Controls the amount of elasticity. Defaults to 0. + lam (float, optional): Controls the amount of elasticity. + Defaults to 0. eval_points (array_like, optional): Set of points where the functions are evaluated, by default uses the sample points of the fdatagrid. @@ -410,8 +415,8 @@ def warping_mean(warping, *, iter=20, tol=1e-5, step_size=1., eval_points=None, step_size (float): Step size :math:`\epsilon` used to update the mean. Default to 1. eval_points (array_like): Discretisation points of the warpings. - shooting (boolean): If true it is returned a tuple with the mean and the - shooting vectors, otherwise only the mean is returned. + shooting (boolean): If true it is returned a tuple with the mean and + the shooting vectors, otherwise only the mean is returned. Return: (:class:`FDataGrid`) Fdatagrid with the mean of the warpings. If @@ -612,14 +617,15 @@ def elastic_mean(fdatagrid, *, lam=0., center=True, iter=20, tol=1e-3, fdatagrid_normalized = fdatagrid_normalized.compose(gammas) srsf = to_srsf(fdatagrid_normalized).data_matrix[..., 0] - # Next iteration + # Next iteration mu_1 = srsf.mean(axis=0, out=mu_1) # Convergence criterion mu_norm = np.sqrt(scipy.integrate.simps(np.square(mu, out=mu_aux), eval_points_normalized)) - mu_diff = np.sqrt(scipy.integrate.simps(np.square(mu - mu_1, out=mu_aux), + mu_diff = np.sqrt(scipy.integrate.simps(np.square(mu - mu_1, + out=mu_aux), eval_points_normalized)) if mu_diff / mu_norm < tol: diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index a43eb61db..4664760d8 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -69,8 +69,8 @@ def landmark_shift_deltas(fd, landmarks, location=None): """ if len(landmarks) != fd.nsamples: - raise ValueError(f"landmark list ({len(landmarks)}) must have the same " - f"length than the number of samples ({fd.nsamples})") + raise ValueError(f"landmark list ({len(landmarks)}) must have the same" + f" length than the number of samples ({fd.nsamples})") landmarks = np.atleast_1d(landmarks) @@ -183,8 +183,8 @@ def landmark_registration_warping(fd, landmarks, *, location=None, Raises: ValueError: If the object to be registered has domain dimension greater - than 1 or the list of landmarks or locations does not match with the - number of samples. + than 1 or the list of landmarks or locations does not match with + the number of samples. References: @@ -195,11 +195,13 @@ def landmark_registration_warping(fd, landmarks, *, location=None, >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples - >>> from skfda.preprocessing.registration import landmark_registration_warping + >>> from skfda.preprocessing.registration import + ... landmark_registration_warping We will create a data with landmarks as example - >>> fd = make_multimodal_samples(n_samples=3, n_modes=2, random_state=9) + >>> fd = make_multimodal_samples(n_samples=3, n_modes=2, + ... random_state=9) >>> landmarks = make_multimodal_landmarks(n_samples=3, n_modes=2, ... random_state=9) >>> landmarks = landmarks.squeeze() @@ -269,8 +271,8 @@ def landmark_registration(fd, landmarks, *, location=None, eval_points=None): """Perform landmark registration of the curves. Let :math:`t_{ij}` the time where the sample :math:`i` has the feature - :math:`j` and :math:`t^*_j` the new time for the feature. The registered - samples will have their features aligned, i.e., + :math:`j` and :math:`t^*_j` the new time for the feature. + The registered samples will have their features aligned, i.e., :math:`x^*_i(t^*_j)=x_i(t_{ij})`. See [RS05-7-3]_ for a detailed explanation. @@ -304,7 +306,8 @@ def landmark_registration(fd, landmarks, *, location=None, eval_points=None): We will create a data with landmarks as example - >>> fd = make_multimodal_samples(n_samples=3, n_modes=2, random_state=9) + >>> fd = make_multimodal_samples(n_samples=3, n_modes=2, + ... random_state=9) >>> landmarks = make_multimodal_landmarks(n_samples=3, n_modes=2, ... random_state=9) >>> landmarks = landmarks.squeeze() diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index 8ff428339..d4278aede 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -97,8 +97,8 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples - >>> from skfda.preprocessing.registration import (landmark_registration_warping, - ... mse_decomposition) + >>> from skfda.preprocessing.registration import + ... landmark_registration_warping, mse_decomposition We will create and register data. @@ -108,7 +108,8 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, >>> landmarks = landmarks.squeeze() >>> warping = landmark_registration_warping(fd, landmarks) >>> fd_registered = fd.compose(warping) - >>> mse_amp, mse_pha, rsq, cr = mse_decomposition(fd, fd_registered, warping) + >>> mse_amp, mse_pha, rsq, cr = mse_decomposition(fd, fd_registered, + ... warping) Mean square error produced by the amplitude variation. @@ -139,8 +140,8 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, if warping_function is not None and (warping_function.nsamples != original_fdata.nsamples): - raise ValueError(f"the registered curves and the warping functions must" - f" have the same number of samples " + raise ValueError(f"the registered curves and the warping functions " + f"must have the same number of samples " f"({registered_fdata.nsamples})" f"!=({warping_function.nsamples})") @@ -186,9 +187,9 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, covariate = np.inner(dh_fine_center.T, y_fine_sq_center.T) covariate = covariate.mean(axis=0) cr += np.divide(scipy.integrate.simps(covariate, - eval_points), - scipy.integrate.simps(eta_fine_sq, - eval_points)) + eval_points), + scipy.integrate.simps(eta_fine_sq, + eval_points)) # mse due to phase variation mse_pha = scipy.integrate.simps(cr*eta_fine_sq - mu_fine_sq, eval_points) diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index c9e22fb0d..6e861497f 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -11,9 +11,9 @@ __email__ = "pablo.marcosm@estudiante.uam.es" -def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, - extrapolation=None, step_size=1, initial=None, - eval_points=None): +def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, + restrict_domain=False, extrapolation=None, + step_size=1, initial=None, eval_points=None): r"""Return the lists of shifts used in the shift registration procedure. Realizes a registration of the curves, using shift aligment, as is @@ -68,8 +68,10 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.basis import Fourier - >>> from skfda.preprocessing.registration import shift_registration_deltas - >>> fd = make_sinusoidal_process(n_samples=2, error_std=0, random_state=1) + >>> from skfda.preprocessing.registration import + ... shift_registration_deltas + >>> fd = make_sinusoidal_process(n_samples=2, error_std=0, + ... random_state=1) Registration of data in discretized form: @@ -251,7 +253,8 @@ def shift_registration(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.basis import Fourier >>> from skfda.preprocessing.registration import shift_registration - >>> fd = make_sinusoidal_process(n_samples=2, error_std=0, random_state=1) + >>> fd = make_sinusoidal_process(n_samples=2, error_std=0, + ... random_state=1) Registration of data in discretized form: @@ -279,7 +282,7 @@ def shift_registration(fd, *, maxiter=5, tol=1e-2, restrict_domain=False, step_size=step_size, initial=initial, eval_points=eval_points) - # Computes the values with the final shift to construct the FDataBasis + # Computes the values with the final shift to construct the FDataBasis return fd.shift(delta, restrict_domain=restrict_domain, extrapolation=extrapolation, eval_points=eval_points, **kwargs) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index b9f656ce1..d39232671 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -204,7 +204,7 @@ def knn(argvals, k=None, kernel=kernels.uniform, w=None, cv=False): # For each row in the distances matrix, it calculates the furthest point # within the k nearest neighbours vec = np.percentile(delta_x, k / len(argvals) * 100, axis=0, - interpolation='lower') + tol + interpolation='lower') + tol rr = kernel((delta_x.T / vec).T) # Applies the kernel to the result of dividing each row by the result From 851525d52cd6fdc4ba10982285d5fe4b8eec60a5 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 21 Jun 2019 12:11:11 +0200 Subject: [PATCH 094/222] # Improve the docs --- docs/apilist.rst | 2 ++ docs/conf.py | 1 + docs/index.rst | 2 ++ docs/modules/preprocessing.rst | 19 +++++++++++++++++++ docs/modules/preprocessing/smoothing.rst | 10 ++++++---- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/docs/apilist.rst b/docs/apilist.rst index c49d0852c..e443d49ce 100644 --- a/docs/apilist.rst +++ b/docs/apilist.rst @@ -2,8 +2,10 @@ API Reference ============= .. toctree:: + :includehidden: :maxdepth: 4 :caption: Modules: + :titlesonly: modules/representation modules/preprocessing diff --git a/docs/conf.py b/docs/conf.py index 33169621c..2b5222123 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -211,6 +211,7 @@ sys.version_info), None), 'numpy': ('https://docs.scipy.org/doc/numpy/', None), 'scipy': ('https://docs.scipy.org/doc/scipy/reference', None), + 'sklearn': ('https://scikit-learn.org/stable', None), 'matplotlib': ('https://matplotlib.org/', None), 'pandas': ('https://pandas.pydata.org/pandas-docs/stable/', None), 'mpldatacursor': ('https://pypi.org/project/mpldatacursor/', None), diff --git a/docs/index.rst b/docs/index.rst index 263192774..f5f999aff 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,8 +6,10 @@ Welcome to scikit-fda's documentation! ====================================== .. toctree:: + :includehidden: :maxdepth: 4 :caption: Contents: + :titlesonly: apilist auto_examples/index diff --git a/docs/modules/preprocessing.rst b/docs/modules/preprocessing.rst index 61c02cca7..06f3eb6da 100644 --- a/docs/modules/preprocessing.rst +++ b/docs/modules/preprocessing.rst @@ -5,8 +5,27 @@ Sometimes we need to preprocess the data prior to analyze it. The modules in this category deal with this problem. .. toctree:: + :titlesonly: :maxdepth: 4 :caption: Modules: + :hidden: preprocessing/smoothing preprocessing/registration + +Smoothing +--------- + +If the functional data observations are noisy, *smoothing* the data allows a +better representation of the true underlying functions. You can learn more +about the smoothing methods provided by scikit-fda +:doc:`here `. + +Registration +------------ + +Sometimes, the functional data may be misaligned, or the phase variation +should be ignored in the analysis. To align the data and eliminate the phase +variation, we need to use *registration* methods. +:doc:`Here ` you can learn more about the +registration methods available in the library. \ No newline at end of file diff --git a/docs/modules/preprocessing/smoothing.rst b/docs/modules/preprocessing/smoothing.rst index 35e3b8992..8426082ec 100644 --- a/docs/modules/preprocessing/smoothing.rst +++ b/docs/modules/preprocessing/smoothing.rst @@ -45,9 +45,10 @@ hat matrix. skfda.preprocessing.smoothing.validation.LinearSmootherLeaveOneOutScorer skfda.preprocessing.smoothing.validation.LinearSmootherGeneralizedCVScorer -The `LinearSmootherGeneralizedCVScorer` object accepts also an optional -penalization_function, used instead of the default one. The available ones -are: +The +:class:`~skfda.preprocessing.smoothing.validation.LinearSmootherGeneralizedCVScorer` +object accepts also an optional penalization_function, used instead of the +default one. The available ones are: .. autosummary:: :toctree: autosummary @@ -57,7 +58,8 @@ are: skfda.preprocessing.smoothing.validation.shibata skfda.preprocessing.smoothing.validation.rice -An utility method is also provided, which calls the sckit-learn `GridSearchCV` +An utility method is also provided, which calls the sckit-learn +:class:`~sklearn.model_selection.GridSearchCV` object with the scorers to find the best smoothing parameters from a list. .. autosummary:: From 4bae3cc54738fa07b6df5dcd33c0f28b08d34cec Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 21 Jun 2019 13:21:23 +0200 Subject: [PATCH 095/222] * Add leave one out cross validation test * Extract the common code of the smoothers to the abstract parent class --- .../smoothing/kernel_smoothers.py | 92 ++++++++++--------- tests/test_smoothing.py | 51 ++++++++++ 2 files changed, 99 insertions(+), 44 deletions(-) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 2fd0bcbe0..995883e32 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -9,12 +9,9 @@ * Closed-form for KNN """ -import math - import numpy as np from ...misc import kernels -from ..._utils import parameter_aliases from sklearn.base import BaseEstimator, TransformerMixin from skfda.representation.grid import FDataGrid import abc @@ -35,9 +32,36 @@ def __init__(self, *, smoothing_parameter=None, self.smoothing_parameter = smoothing_parameter self.kernel = kernel self.weights = weights + self._cv = False # For testing purposes only + + def _hat_matrix_function(self, *, input_points, smoothing_parameter, + kernel, weights, _cv=False): + + # Time deltas + delta_x = np.abs(np.subtract.outer(input_points, input_points)) + + # Obtain the non-normalized matrix + matrix = self._hat_matrix_function_not_normalized( + delta_x=delta_x, + smoothing_parameter=smoothing_parameter, + kernel=kernel) + + # Adjust weights + if weights is not None: + matrix = matrix * weights + + # Set diagonal to cero if requested (for testing purposes only) + if _cv: + np.fill_diagonal(matrix, 0) + + # Renormalize weights + rs = np.sum(matrix, 1) + rs[rs == 0] = 1 + return (matrix.T / rs).T @abc.abstractmethod - def _hat_matrix_function(self): + def _hat_matrix_function_not_normalized(self, *, delta_x, + smoothing_parameter, kernel): pass def _more_tags(self): @@ -55,7 +79,8 @@ def fit(self, X: FDataGrid, y=None): input_points=self.input_points_, smoothing_parameter=self.smoothing_parameter, kernel=self.kernel, - weights=self.weights + weights=self.weights, + _cv=self._cv ) return self @@ -114,20 +139,14 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 0.006, 0.022, 0.163, 0.305, 0.503]]) """ - def _hat_matrix_function(self, *, input_points, smoothing_parameter, - kernel, weights, _cv=False): - delta_x = np.abs(np.subtract.outer(input_points, input_points)) + def _hat_matrix_function_not_normalized(self, *, delta_x, + smoothing_parameter, + kernel): if smoothing_parameter is None: smoothing_parameter = np.percentile(delta_x, 15) - if _cv: - np.fill_diagonal(delta_x, math.inf) - delta_x = delta_x / smoothing_parameter - k = kernel(delta_x) - if weights is not None: - k = k * weights - rs = np.sum(k, 1) - rs[rs == 0] = 1 - return (k.T / rs).T + + k = kernel(delta_x / smoothing_parameter) + return k class LocalLinearRegressionSmoother(_LinearKernelSmoother): @@ -183,22 +202,14 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): """ - def _hat_matrix_function(self, *, input_points, smoothing_parameter, - kernel, weights, _cv=False): - delta_x = np.abs(np.subtract.outer(input_points, - input_points)) # x_i - x_j - if _cv: - np.fill_diagonal(delta_x, math.inf) - k = kernel(delta_x / smoothing_parameter) # K(x_i - x/ h) + def _hat_matrix_function_not_normalized(self, *, delta_x, + smoothing_parameter, kernel): + k = kernel(delta_x / smoothing_parameter) + s1 = np.sum(k * delta_x, 1) # S_n_1 s2 = np.sum(k * delta_x ** 2, 1) # S_n_2 b = (k * (s2 - delta_x * s1)).T # b_i(x_j) - if _cv: - np.fill_diagonal(b, 0) - if weights is not None: - b = b * weights - rs = np.sum(b, 1) # sum_{k=1}^{n}b_k(x_j) - return (b.T / rs).T # \\hat{H} + return b class KNeighborsSmoother(_LinearKernelSmoother): @@ -251,18 +262,16 @@ def __init__(self, *, smoothing_parameter=None, weights=weights ) - def _hat_matrix_function(self, *, input_points, smoothing_parameter, - kernel, weights, _cv=False): - # Distances matrix of points in argvals - delta_x = np.abs(np.subtract.outer(input_points, input_points)) + def _hat_matrix_function_not_normalized(self, *, delta_x, + smoothing_parameter, kernel): + + input_points_len = delta_x.shape[1] if smoothing_parameter is None: smoothing_parameter = np.floor(np.percentile( - range(1, len(input_points)), 5)) + range(1, input_points_len), 5)) elif smoothing_parameter <= 0: raise ValueError('h must be greater than 0') - if _cv: - np.fill_diagonal(delta_x, math.inf) # Tolerance to avoid points landing outside the kernel window due to # computation error @@ -271,7 +280,7 @@ def _hat_matrix_function(self, *, input_points, smoothing_parameter, # For each row in the distances matrix, it calculates the furthest # point within the k nearest neighbours vec = np.percentile(delta_x, smoothing_parameter - / len(input_points) * 100, + / input_points_len * 100, axis=0, interpolation='lower') + tol rr = kernel((delta_x.T / vec).T) @@ -280,9 +289,4 @@ def _hat_matrix_function(self, *, input_points, smoothing_parameter, # corresponding to the knn are below 1 and the rest above 1 so the # kernel returns values distinct to 0 only for the knn. - if weights is not None: - rr = (rr.T * weights).T - - # normalise every row - rs = np.sum(rr, 1) - return (rr.T / rs).T + return rr diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py index ffe088e62..94a1596b4 100644 --- a/tests/test_smoothing.py +++ b/tests/test_smoothing.py @@ -1,6 +1,11 @@ import unittest from skfda._utils import _check_estimator +import skfda import skfda.preprocessing.smoothing.kernel_smoothers as kernel_smoothers +import skfda.preprocessing.smoothing.validation as validation + +import numpy as np +import sklearn class TestSklearnEstimators(unittest.TestCase): @@ -13,3 +18,49 @@ def test_local_linear_regression(self): def test_knn(self): _check_estimator(kernel_smoothers.KNeighborsSmoother) + + +class _LinearSmootherLeaveOneOutScorerAlternative(): + r"""Alternative implementation of the LinearSmootherLeaveOneOutScorer""" + + def __call__(self, estimator, X, y): + estimator_clone = sklearn.base.clone(estimator) + + estimator_clone._cv = True + y_est = estimator_clone.fit_transform(X) + + return -np.mean((y.data_matrix[..., 0] - y_est.data_matrix[..., 0])**2) + + +class TestLeaveOneOut(unittest.TestCase): + + def _test_generic(self, estimator_class): + loo_scorer = validation.LinearSmootherLeaveOneOutScorer() + loo_scorer_alt = _LinearSmootherLeaveOneOutScorerAlternative() + x = np.linspace(-2, 2, 5) + fd = skfda.FDataGrid(x ** 2, x) + + estimator = estimator_class() + + grid = validation.optimize_smoothing_parameter( + fd, [2, 3], + smoothing_method=estimator, + cv_method=loo_scorer) + score = np.array(grid.cv_results_['mean_test_score']) + + grid_alt = validation.optimize_smoothing_parameter( + fd, [2, 3], + smoothing_method=estimator, + cv_method=loo_scorer_alt) + score_alt = np.array(grid_alt.cv_results_['mean_test_score']) + + np.testing.assert_array_almost_equal(score, score_alt) + + def test_nadaraya_watson(self): + self._test_generic(kernel_smoothers.NadarayaWatsonSmoother) + + def test_local_linear_regression(self): + self._test_generic(kernel_smoothers.LocalLinearRegressionSmoother) + + def test_knn(self): + self._test_generic(kernel_smoothers.KNeighborsSmoother) From 62fbae833e22e7cc5733c1977b1d56dee850f166 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 21:17:56 +0200 Subject: [PATCH 096/222] Fixed PEP8 on preprocessing: tests --- skfda/preprocessing/registration/_landmark_registration.py | 4 ++-- skfda/preprocessing/registration/_registration_utils.py | 4 ++-- skfda/preprocessing/registration/_shift_registration.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index 4664760d8..ae89e5a77 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -195,8 +195,8 @@ def landmark_registration_warping(fd, landmarks, *, location=None, >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples - >>> from skfda.preprocessing.registration import - ... landmark_registration_warping + >>> from skfda.preprocessing.registration import ( + ... landmark_registration_warping) We will create a data with landmarks as example diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index d4278aede..926b54670 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -97,8 +97,8 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, >>> from skfda.datasets import make_multimodal_landmarks >>> from skfda.datasets import make_multimodal_samples - >>> from skfda.preprocessing.registration import - ... landmark_registration_warping, mse_decomposition + >>> from skfda.preprocessing.registration import ( + ... landmark_registration_warping, mse_decomposition) We will create and register data. diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 6e861497f..df734618b 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -68,8 +68,8 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, >>> from skfda.datasets import make_sinusoidal_process >>> from skfda.representation.basis import Fourier - >>> from skfda.preprocessing.registration import - ... shift_registration_deltas + >>> from skfda.preprocessing.registration import ( + ... shift_registration_deltas) >>> fd = make_sinusoidal_process(n_samples=2, error_std=0, ... random_state=1) From 5bf253c62a4e0f88f2bbbb1d54e724b317457aed Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 21:18:16 +0200 Subject: [PATCH 097/222] Fixed PEP8 on exploratory --- skfda/exploratory/depth.py | 168 +++++++++++------ skfda/exploratory/visualization/boxplot.py | 143 ++++++++------ .../visualization/clustering_plots.py | 108 ++++++----- .../visualization/magnitude_shape_plot.py | 177 ++++++++++-------- 4 files changed, 362 insertions(+), 234 deletions(-) diff --git a/skfda/exploratory/depth.py b/skfda/exploratory/depth.py index 619d472e8..596acf3c2 100644 --- a/skfda/exploratory/depth.py +++ b/skfda/exploratory/depth.py @@ -25,8 +25,10 @@ def _rank_samples(fdatagrid): Examples: Univariate setting: - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> _rank_samples(fd) @@ -60,8 +62,12 @@ def _rank_samples(fdatagrid): Multivariate Setting: - >>> data_matrix = [[[[1, 3], [2, 6]], [[23, 54], [43, 76]], [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], [[67, 43], [32, 21]], [[10, 24], [3, 12]]]] + >>> data_matrix = [[[[1, 3], [2, 6]], + ... [[23, 54], [43, 76]], + ... [[2, 45], [12, 65]]], + ... [[[21, 34], [8, 16]], + ... [[67, 43], [32, 21]], + ... [[10, 24], [3, 12]]]] >>> sample_points = [[2, 4, 6], [3, 6]] >>> fd = FDataGrid(data_matrix, sample_points) >>> _rank_samples(fd) @@ -85,7 +91,10 @@ def _rank_samples(fdatagrid): [ 1., 1.]]]]) """ ranks = np.zeros(fdatagrid.shape) - ncols_dim_image = np.asarray([range(fdatagrid.shape[i]) for i in range(len(fdatagrid.shape) - 1, 0, -1)]) + ncols_dim_image = np.asarray([range(fdatagrid.shape[i]) + for i in range(len(fdatagrid.shape) - 1, + 0, -1) + ]) tuples = list(itertools.product(*ncols_dim_image)) for t in tuples: ranks.T[t] = rankdata(fdatagrid.data_matrix.T[t], method='max') @@ -95,26 +104,33 @@ def _rank_samples(fdatagrid): def band_depth(fdatagrid, pointwise=False): """Implementation of Band Depth for functional data. - The band depth of each sample is obtained by computing the fraction of the bands determined by two sample - curves containing the whole graph of the first one. In the case the fdatagrid domain dimension is 2, instead - of curves, surfaces determine the bands. In larger dimensions, the hyperplanes determine the bands. + The band depth of each sample is obtained by computing the fraction of the + bands determined by two sample curves containing the whole graph of the + first one. In the case the fdatagrid domain dimension is 2, instead of + curves, surfaces determine the bands. In larger dimensions, the hyperplanes + determine the bands. Args: - fdatagrid (FDataGrid): Object over whose samples the band depth is going to be calculated. - pointwise (boolean, optional): Indicates if the pointwise depth is also returned. Defaults to False. + fdatagrid (FDataGrid): Object over whose samples the band depth is + going to be calculated. + pointwise (boolean, optional): Indicates if the pointwise depth is also + returned. Defaults to False. Returns: depth (numpy.darray): Array containing the band depth of the samples. Returns: - depth_pointwise (numpy.darray, optional): Array containing the band depth of - the samples at each point of discretisation. Only returned if pointwise equals to True. + depth_pointwise (numpy.darray, optional): Array containing the band + depth of the samples at each point of discretisation. Only returned + if pointwise equals to True. Examples: Univariate setting: - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> band_depth(fd) @@ -125,9 +141,15 @@ def band_depth(fdatagrid, pointwise=False): Multivariate Setting: - >>> data_matrix = [[[[1, 3], [2, 6]], [[23, 54], [43, 76]], [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], [[67, 43], [32, 21]], [[10, 24], [3, 12]]], - ... [[[4, 6], [4, 10]], [[45, 48], [38, 56]], [[8, 36], [10, 28]]]] + >>> data_matrix = [[[[1, 3], [2, 6]], + ... [[23, 54], [43, 76]], + ... [[2, 45], [12, 65]]], + ... [[[21, 34], [8, 16]], + ... [[67, 43], [32, 21]], + ... [[10, 24], [3, 12]]], + ... [[[4, 6], [4, 10]], + ... [[45, 48], [38, 56]], + ... [[8, 36], [10, 28]]]] >>> sample_points = [[2, 4, 6], [3, 6]] >>> fd = FDataGrid(data_matrix, sample_points) >>> band_depth(fd) @@ -143,7 +165,8 @@ def band_depth(fdatagrid, pointwise=False): axis = tuple(range(1, fdatagrid.ndim_domain + 1)) nsamples_above = fdatagrid.nsamples - np.amax(ranks, axis=axis) nsamples_below = np.amin(ranks, axis=axis) - 1 - depth = (nsamples_below * nsamples_above + fdatagrid.nsamples - 1) / nchoose2 + depth = ((nsamples_below * nsamples_above + fdatagrid.nsamples - 1) / + nchoose2) if pointwise: _, depth_pointwise = modified_band_depth(fdatagrid, pointwise) @@ -155,26 +178,34 @@ def band_depth(fdatagrid, pointwise=False): def modified_band_depth(fdatagrid, pointwise=False): """Implementation of Modified Band Depth for functional data. - The band depth of each sample is obtained by computing the fraction of time its graph is contained - in the bands determined by two sample curves. In the case the fdatagrid domain dimension is 2, instead - of curves, surfaces determine the bands. In larger dimensions, the hyperplanes determine the bands. + The band depth of each sample is obtained by computing the fraction of time + its graph is contained in the bands determined by two sample curves. + In the case the fdatagrid domain dimension is 2, instead of curves, + surfaces determine the bands. In larger dimensions, the hyperplanes + determine the bands. Args: - fdatagrid (FDataGrid): Object over whose samples the modified band depth is going to be calculated. - pointwise (boolean, optional): Indicates if the pointwise depth is also returned. Defaults to False. + fdatagrid (FDataGrid): Object over whose samples the modified band + depth is going to be calculated. + pointwise (boolean, optional): Indicates if the pointwise depth is + also returned. Defaults to False. Returns: - depth (numpy.darray): Array containing the modified band depth of the samples. + depth (numpy.darray): Array containing the modified band depth of the + samples. Returns: - depth_pointwise (numpy.darray, optional): Array containing the modified band depth of - the samples at each point of discretisation. Only returned if pointwise equals to True. + depth_pointwise (numpy.darray, optional): Array containing the modified + band depth of the samples at each point of discretisation. Only + returned if pointwise equals to True. Examples: Univariate setting specifying pointwise: - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> modified_band_depth(fd, pointwise = True) @@ -211,9 +242,15 @@ def modified_band_depth(fdatagrid, pointwise=False): Multivariate Setting without specifying pointwise: - >>> data_matrix = [[[[1, 3], [2, 6]], [[23, 54], [43, 76]], [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], [[67, 43], [32, 21]], [[10, 24], [3, 12]]], - ... [[[4, 6], [4, 10]], [[45, 48], [38, 56]], [[34, 78], [10, 28]]]] + >>> data_matrix = [[[[1, 3], [2, 6]], + ... [[23, 54], [43, 76]], + ... [[2, 45], [12, 65]]], + ... [[[21, 34], [8, 16]], + ... [[67, 43], [32, 21]], + ... [[10, 24], [3, 12]]], + ... [[[4, 6], [4, 10]], + ... [[45, 48], [38, 56]], + ... [[34, 78], [10, 28]]]] >>> sample_points = [[2, 4, 6], [3, 6]] >>> fd = FDataGrid(data_matrix, sample_points) >>> modified_band_depth(fd) @@ -230,7 +267,8 @@ def modified_band_depth(fdatagrid, pointwise=False): nsamples_below = ranks - 1 match = nsamples_above * nsamples_below axis = tuple(range(1, fdatagrid.ndim_domain + 1)) - npoints_sample = reduce(lambda x, y: x * len(y), fdatagrid.sample_points, 1) + npoints_sample = reduce(lambda x, y: x * len(y), + fdatagrid.sample_points, 1) proportion = match.sum(axis=axis) / npoints_sample depth = (proportion + fdatagrid.nsamples - 1) / nchoose2 @@ -242,13 +280,16 @@ def modified_band_depth(fdatagrid, pointwise=False): def _cumulative_distribution(column): - """Calculates the cumulative distribution function of the values passed to the function and evaluates it at each point. + """Calculates the cumulative distribution function of the values passed to + the function and evaluates it at each point. Args: - column (numpy.darray): Array containing the values over which the distribution function is calculated. + column (numpy.darray): Array containing the values over which the + distribution function is calculated. Returns: - numpy.darray: Array containing the evaluation at each point of the distribution function. + numpy.darray: Array containing the evaluation at each point of the + distribution function. Examples: >>> _cumulative_distribution(np.array([1, 4, 5, 1, 2, 2, 4, 1, 1, 3])) @@ -257,7 +298,8 @@ def _cumulative_distribution(column): """ if len(column.shape) != 1: raise ValueError("Only supported 1 dimensional arrays.") - _, indexes, counts = np.unique(column, return_inverse=True, return_counts=True) + _, indexes, counts = np.unique(column, return_inverse=True, + return_counts=True) count_cumulative = np.cumsum(counts) / len(column) return count_cumulative[indexes].reshape(column.shape) @@ -265,32 +307,40 @@ def _cumulative_distribution(column): def fraiman_muniz_depth(fdatagrid, pointwise=False): r"""Implementation of Fraiman and Muniz (FM) Depth for functional data. - Each column is considered as the samples of an aleatory variable. The univariate depth of each of the samples of each column is - calculated as follows: + Each column is considered as the samples of an aleatory variable. + The univariate depth of each of the samples of each column is calculated + as follows: .. math:: D(x) = 1 - \left\lvert \frac{1}{2}- F(x)\right\rvert - Where :math:`F` stands for the marginal univariate distribution function of each column. + Where :math:`F` stands for the marginal univariate distribution function of + each column. - The depth of a sample is the result of adding the previously computed depth for each of its points. + The depth of a sample is the result of adding the previously computed depth + for each of its points. Args: - fdatagrid (FDataGrid): Object over whose samples the FM depth is going to be calculated. - pointwise (boolean, optional): Indicates if the pointwise depth is also returned. Defaults to False. + fdatagrid (FDataGrid): Object over whose samples the FM depth is going + to be calculated. + pointwise (boolean, optional): Indicates if the pointwise depth is also + returned. Defaults to False. Returns: depth (numpy.darray): Array containing the FM depth of the samples. Returns: - depth_pointwise (numpy.darray, optional): Array containing the FM depth of - the samples at each point of discretisation. Only returned if pointwise equals to True. + depth_pointwise (numpy.darray, optional): Array containing the FM depth + of the samples at each point of discretisation. Only returned if + pointwise equals to True. Examples: Univariate setting specifying pointwise: - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> fraiman_muniz_depth(fd, pointwise = True) @@ -327,9 +377,15 @@ def fraiman_muniz_depth(fdatagrid, pointwise=False): Multivariate Setting without specifying pointwise: - >>> data_matrix = [[[[1, 3], [2, 6]], [[23, 54], [43, 76]], [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], [[67, 43], [32, 21]], [[10, 24], [3, 12]]], - ... [[[4, 6], [4, 10]], [[45, 48], [38, 56]], [[34, 78], [10, 28]]]] + >>> data_matrix = [[[[1, 3], [2, 6]], + ... [[23, 54], [43, 76]], + ... [[2, 45], [12, 65]]], + ... [[[21, 34], [8, 16]], + ... [[67, 43], [32, 21]], + ... [[10, 24], [3, 12]]], + ... [[[4, 6], [4, 10]], + ... [[45, 48], [38, 56]], + ... [[34, 78], [10, 28]]]] >>> sample_points = [[2, 4, 6], [3, 6]] >>> fd = FDataGrid(data_matrix, sample_points) >>> fraiman_muniz_depth(fd) @@ -340,16 +396,22 @@ def fraiman_muniz_depth(fdatagrid, pointwise=False): """ univariate_depth = np.zeros(fdatagrid.shape) - ncols_dim_image = np.asarray([range(fdatagrid.shape[i]) for i in range(len(fdatagrid.shape) - 1, 0, -1)]) + ncols_dim_image = np.asarray([range(fdatagrid.shape[i]) + for i in range(len(fdatagrid.shape) - 1, + 0, -1) + ]) + tuples = list(itertools.product(*ncols_dim_image)) for t in tuples: column = fdatagrid.data_matrix.T[t] - univariate_depth.T[t] = 1 - np.abs(0.5 - _cumulative_distribution(column)) + univariate_depth.T[t] = 1 - abs(0.5 - _cumulative_distribution(column)) axis = tuple(range(1, fdatagrid.ndim_domain + 1)) - npoints_sample = reduce(lambda x, y: x * len(y), fdatagrid.sample_points, 1) + npoints_sample = reduce(lambda x, y: x * len(y), + fdatagrid.sample_points, 1) if pointwise: - return np.sum(univariate_depth, axis=axis) / npoints_sample, univariate_depth + return (np.sum(univariate_depth, axis=axis) / npoints_sample, + univariate_depth) else: return np.sum(univariate_depth, axis=axis) / npoints_sample diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/boxplot.py index 72d22017f..4435e2eac 100644 --- a/skfda/exploratory/visualization/boxplot.py +++ b/skfda/exploratory/visualization/boxplot.py @@ -18,22 +18,23 @@ __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" + class FDataBoxplot(ABC): """Abstract class inherited by the Boxplot and SurfaceBoxplot classes. - It the data of the functional boxplot or surface boxplot of a FDataGrid object, - depending on the dimensions of the domain, 1 or 2 respectively. + It the data of the functional boxplot or surface boxplot of a FDataGrid + object, depending on the dimensions of the domain, 1 or 2 respectively. - It forces to both classes, Boxplot and SurfaceBoxplot to conain at least the median, - central and outlying envelopes and a colormap for their graphical representation, - obtained calling the plot method. + It forces to both classes, Boxplot and SurfaceBoxplot to conain at least + the median, central and outlying envelopes and a colormap for their + graphical representation, obtained calling the plot method. """ @abstractmethod def __init__(self, factor=1.5): if factor < 0: - raise ValueError( - "The number used to calculate the outlying envelope must be positive.") + raise ValueError("The number used to calculate the " + "outlying envelope must be positive.") self._factor = factor @property @@ -63,8 +64,8 @@ def colormap(self): @colormap.setter def colormap(self, value): if not isinstance(value, matplotlib.colors.LinearSegmentedColormap): - raise ValueError( - "colormap must be of type matplotlib.colors.LinearSegmentedColormap") + raise ValueError("colormap must be of type " + "matplotlib.colors.LinearSegmentedColormap") self._colormap = value @abstractmethod @@ -84,15 +85,17 @@ def _repr_svg_(self): class Boxplot(FDataBoxplot): r"""Representation of the functional boxplot. - Class implementing the functionl boxplot which is an informative exploratory - tool for visualizing functional data, as well as its generalization, the - enhanced functional boxplot. Only supports 1 dimensional domain functional data. + Class implementing the functionl boxplot which is an informative + exploratory tool for visualizing functional data, as well as its + generalization, the enhanced functional boxplot. Only supports 1 + dimensional domain functional data. - Based on the center outward ordering induced by a :ref:`depth measure ` - for functional data, the descriptive statistics of a functional boxplot are: the - envelope of the 50% central region, the median curve,and the maximum non-outlying envelope. - In addition, outliers can be detected in a functional boxplot by the 1.5 times the 50% - central region empirical rule, analogous to the rule for classical boxplots. + Based on the center outward ordering induced by a :ref:`depth measure + ` for functional data, the descriptive statistics of a + functional boxplot are: the envelope of the 50% central region, the median + curve,and the maximum non-outlying envelope. In addition, outliers can be + detected in a functional boxplot by the 1.5 times the 50% central region + empirical rule, analogous to the rule for classical boxplots. Attributes: fdatagrid (FDataGrid): Object containing the data. @@ -112,15 +115,19 @@ class Boxplot(FDataBoxplot): outliercol (string): Color of the ouliers. mediancol (string): Color of the median. show_full_outliers (boolean): If False (the default) then only the part - outside the box is plotted. If True, complete outling curves are plotted + outside the box is plotted. If True, complete outling curves are + plotted Example: Function :math:`f : \mathbb{R}\longmapsto\mathbb{R}`. - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], [-1, -1, -0.5, 1, 1, 0.5], + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] - >>> fd = FDataGrid(data_matrix, sample_points, dataset_label="dataset", axes_labels=["x_label", "y_label"]) + >>> fd = FDataGrid(data_matrix, sample_points, dataset_label="dataset", + ... axes_labels=["x_label", "y_label"]) >>> Boxplot(fd) Boxplot( FDataGrid=FDataGrid( @@ -156,12 +163,14 @@ class Boxplot(FDataBoxplot): dataset_label='dataset', axes_labels=['x_label', 'y_label'], extrapolation=None, - interpolator=SplineInterpolator(interpolation_order=1, smoothness_parameter=0.0, monotone=False), + interpolator=SplineInterpolator(interpolation_order=1, + smoothness_parameter=0.0, monotone=False), keepdims=False), median=array([[ 0.5, 0.5, 1. , 2. , 1.5, 1. ]]), central envelope=array([[[ 0.5, 0.5, 1. , 2. , 1.5, 1. ], [-1. , -1. , -0.5, 1. , 1. , 0.5]]]), - outlying envelope=array([[[ 1. , 1. , 2. , 3. , 2.25, 1.75], + outlying envelope=array([[[ 1. , 1. , 2. , 3. , 2.25, + 1.75], [-1. , -1. , -0.5 , -0.5 , 0.25, -0.25]]]), central_regions=array([[[ 0.5, 0.5, 1. , 2. , 1.5, 1. ], [-1. , -1. , -0.5, 1. , 1. , 0.5]]]), @@ -178,8 +187,9 @@ def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], method (:ref:`depth measure `, optional): Method used to order the data. Defaults to :func:`modified band depth `. - prob (list of float, optional): List with float numbers (in the range - from 1 to 0) that indicate which central regions to represent. + prob (list of float, optional): List with float numbers (in the + range from 1 to 0) that indicate which central regions to + represent. Defaults to [0.5] which represents the 50% central region. factor (double): Number used to calculate the outlying envelope. @@ -248,8 +258,8 @@ def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], outliers_below = ( outlying_min_envelope > fdatagrid.data_matrix[ j, :, m]) - if ( - outliers_above.sum() > 0 or outliers_below.sum() > 0): + if (outliers_above.sum() > 0 or + outliers_below.sum() > 0): self._outliers[m, j] = 1 # central regions self._central_regions[ncentral_regions * m + i] = np.asarray( @@ -302,20 +312,21 @@ def show_full_outliers(self, boolean): self._show_full_outliers = boolean def plot(self, fig=None, ax=None, nrows=None, ncols=None): - """Visualization of the functional boxplot of the fdatagrid (ndim_domain=1). + """Visualization of the functional boxplot of the fdatagrid + (ndim_domain=1). Args: fig (figure object, optional): figure over with the graphs are - plotted in case ax is not specified. If None and ax is also None, - the figure is initialized. + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. ax (list of axis objects, optional): axis over where the graphs are plotted. If None, see param fig. nrows(int, optional): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int, optional): designates the number of columns of the figure - to plot the different dimensions of the image. Only specified - if fig and ax are None. + ncols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. Returns: fig (figure object): figure object in which the graphs are plotted. @@ -399,14 +410,15 @@ def __repr__(self): class SurfaceBoxplot(FDataBoxplot): r"""Representation of the surface boxplot. - Class implementing the surface boxplot. Analogously to the functional boxplot, - it is an informative exploratory tool for visualizing functional data with - domain dimension 2. Nevertheless, it does not implement the enhanced - surface boxplot. + Class implementing the surface boxplot. Analogously to the functional + boxplot, it is an informative exploratory tool for visualizing functional + data with domain dimension 2. Nevertheless, it does not implement the + enhanced surface boxplot. - Based on the center outward ordering induced by a :ref:`depth measure ` - for functional data, it represents the envelope of the 50% central region, the median curve, - and the maximum non-outlying envelope. + Based on the center outward ordering induced by a :ref:`depth measure + ` for functional data, it represents the envelope of the + 50% central region, the median curve, and the maximum non-outlying + envelope. Attributes: fdatagrid (FDataGrid): Object containing the data. @@ -418,17 +430,21 @@ class SurfaceBoxplot(FDataBoxplot): contains the outlying envelope/s. colormap (matplotlib.colors.LinearSegmentedColormap): Colormap from which the colors to represent the central regions are selected. - boxcol (string): Color of the box, which includes median and central envelope. + boxcol (string): Color of the box, which includes median and central + envelope. outcol (string): Color of the outlying envelope. Example: Function :math:`f : \mathbb{R^2}\longmapsto\mathbb{R^2}`. - >>> data_matrix = [[[[1, 4], [0.3, 1.5], [1, 3]], [[2, 8], [0.4, 2], [2, 9]]], - ... [[[2, 10], [0.5, 3], [2, 10]], [[3, 12], [0.6, 3], [3, 15]]]] + >>> data_matrix = [[[[1, 4], [0.3, 1.5], [1, 3]], + ... [[2, 8], [0.4, 2], [2, 9]]], + ... [[[2, 10], [0.5, 3], [2, 10]], + ... [[3, 12], [0.6, 3], [3, 15]]]] >>> sample_points = [[2, 4], [3, 6, 8]] - >>> fd = FDataGrid(data_matrix, sample_points, dataset_label= "dataset", - ... axes_labels=["x1_label", "x2_label", "y1_label", "y2_label"]) + >>> fd = FDataGrid(data_matrix, sample_points, dataset_label="dataset", + ... axes_labels=["x1_label", "x2_label", + ... "y1_label", "y2_label"]) >>> SurfaceBoxplot(fd) SurfaceBoxplot( FDataGrid=FDataGrid( @@ -454,7 +470,8 @@ class SurfaceBoxplot(FDataBoxplot): dataset_label='dataset', axes_labels=['x1_label', 'x2_label', 'y1_label', 'y2_label'], extrapolation=None, - interpolator=SplineInterpolator(interpolation_order=1, smoothness_parameter=0.0, monotone=False), + interpolator=SplineInterpolator(interpolation_order=1, + smoothness_parameter=0.0, monotone=False), keepdims=False), median=array([[[ 1. , 0.3, 1. ], [ 2. , 0.4, 2. ]], @@ -497,8 +514,9 @@ def __init__(self, fdatagrid, method=modified_band_depth, factor=1.5): method (:ref:`depth measure `, optional): Method used to order the data. Defaults to :func:`modified band depth `. - prob (list of float, optional): List with float numbers (in the range - from 1 to 0) that indicate which central regions to represent. + prob (list of float, optional): List with float numbers (in the + range from 1 to 0) that indicate which central regions to + represent. Defaults to [0.5] which represents the 50% central region. factor (double): Number used to calculate the outlying envelope. @@ -590,23 +608,25 @@ def outcol(self, value): self._outcol = value def plot(self, fig=None, ax=None, nrows=None, ncols=None): - """Visualization of the surface boxplot of the fdatagrid (ndim_domain=2). + """Visualization of the surface boxplot of the fdatagrid + (ndim_domain=2). Args: fig (figure object, optional): figure over with the graphs are - plotted in case ax is not specified. If None and ax is also None, - the figure is initialized. - ax (list of axis objects, optional): axis over where the graphs are - plotted. If None, see param fig. + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. + ax (list of axis objects, optional): axis over where the graphs + are plotted. If None, see param fig. nrows(int, optional): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int, optional): designates the number of columns of the figure - to plot the different dimensions of the image. Only specified - if fig and ax are None. + ncols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. Returns: - fig (figure object): figure object in which the graphs are plotted. + fig (figure object): figure object in which the graphs are + plotted. ax (axes object): axes in which the graphs are plotted. """ @@ -689,8 +709,9 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): def __repr__(self): """Return repr(self).""" - return (f"SurfaceBoxplot(" - f"\nFDataGrid={repr(self.fdatagrid)}," - f"\nmedian={repr(self.median)}," - f"\ncentral envelope={repr(self.central_envelope)}," - f"\noutlying envelope={repr(self.outlying_envelope)})").replace('\n', '\n ') + return ((f"SurfaceBoxplot(" + f"\nFDataGrid={repr(self.fdatagrid)}," + f"\nmedian={repr(self.median)}," + f"\ncentral envelope={repr(self.central_envelope)}," + f"\noutlying envelope={repr(self.outlying_envelope)})") + .replace('\n', '\n ')) diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering_plots.py index 2a2a2385f..dbb11a35d 100644 --- a/skfda/exploratory/visualization/clustering_plots.py +++ b/skfda/exploratory/visualization/clustering_plots.py @@ -50,7 +50,6 @@ def _lighten(color, amount=0): return _change_luminosity(color, 0.5 + amount/2) - def _check_if_estimator(estimator): """Checks the argument *estimator* is actually an estimator that implements the *fit* method. @@ -78,16 +77,18 @@ def _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, into different clusters. sample_colors (list of colors): contains in order the colors of each sample of the fdatagrid. - sample_labels (list of str): contains in order the labels of each sample - of the fdatagrid. + sample_labels (list of str): contains in order the labels of each + sample of the fdatagrid. cluster_colors (list of colors): contains in order the colors of each cluster the samples of the fdatagrid are classified into. cluster_labels (list of str): contains in order the names of each cluster the samples of the fdatagrid are classified into. center_colors (list of colors): contains in order the colors of each - centroid of the clusters the samples of the fdatagrid are classified into. + centroid of the clusters the samples of the fdatagrid are + classified into. center_labels list of colors): contains in order the labels of each - centroid of the clusters the samples of the fdatagrid are classified into. + centroid of the clusters the samples of the fdatagrid are + classified into. """ @@ -146,23 +147,27 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, labels (numpy.ndarray, int: (nsamples, ndim_image)): 2-dimensional matrix where each row contains the number of cluster cluster that observation belongs to. - sample_labels (list of str): contains in order the labels of each sample - of the fdatagrid. + sample_labels (list of str): contains in order the labels of each + sample of the fdatagrid. cluster_colors (list of colors): contains in order the colors of each cluster the samples of the fdatagrid are classified into. cluster_labels (list of str): contains in order the names of each cluster the samples of the fdatagrid are classified into. center_colors (list of colors): contains in order the colors of each - centroid of the clusters the samples of the fdatagrid are classified into. + centroid of the clusters the samples of the fdatagrid are + classified into. center_labels list of colors): contains in order the labels of each - centroid of the clusters the samples of the fdatagrid are classified into. + centroid of the clusters the samples of the fdatagrid are + classified into. center_width (int): width of the centroids. - colormap(colormap): colormap from which the colors of the plot are taken. + colormap(colormap): colormap from which the colors of the plot are + taken. Returns: (tuple): tuple containing: - fig (figure object): figure object in which the graphs are plotted in case ax is None. + fig (figure object): figure object in which the graphs are plotted + in case ax is None. ax (axes object): axes in which the graphs are plotted. """ @@ -208,7 +213,8 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, for i in range(estimator.n_clusters): ax[j].plot(fdatagrid.sample_points[0], estimator.cluster_centers_.data_matrix[i, :, j], - c=center_colors[i], label=center_labels[i], + c=center_colors[i], + label=center_labels[i], linewidth=center_width) ax[j].legend(handles=patches) datacursor(formatter='{label}'.format) @@ -246,16 +252,18 @@ def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, ncols(int): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - sample_labels (list of str): contains in order the labels of each sample - of the fdatagrid. + sample_labels (list of str): contains in order the labels of each + sample of the fdatagrid. cluster_colors (list of colors): contains in order the colors of each cluster the samples of the fdatagrid are classified into. cluster_labels (list of str): contains in order the names of each cluster the samples of the fdatagrid are classified into. center_colors (list of colors): contains in order the colors of each - centroid of the clusters the samples of the fdatagrid are classified into. + centroid of the clusters the samples of the fdatagrid are + classified into. center_labels (list of colors): contains in order the labels of each - centroid of the clusters the samples of the fdatagrid are classified into. + centroid of the clusters the samples of the fdatagrid are + classified into. center_width (int): width of the centroid curves. colormap(colormap): colormap from which the colors of the plot are taken. Defaults to `rainbow`. @@ -286,7 +294,7 @@ def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, cluster_colors=cluster_colors, cluster_labels=cluster_labels, center_colors=center_colors, - center_labels=center_labels, + center_labels=center_labels, center_width=center_width, colormap=colormap) @@ -295,20 +303,23 @@ def _set_labels(xlabel, ylabel, title, xlabel_str): """Sets the arguments *xlabel*, *ylabel*, *title* passed to the plot functions :func:`plot_cluster_lines ` and - :func:`plot_cluster_bars `, + :func:`plot_cluster_bars + `, in case they are not set yet. Args: xlabel (lstr): Label for the x-axes. ylabel (str): Label for the y-axes. - title (str): Title for the figure where the clustering results are ploted. + title (str): Title for the figure where the clustering results are + ploted. xlabel_str (str): In case xlabel is None, string to use for the labels in the x-axes. Returns: xlabel (str): Labels for the x-axes. ylabel (str): Labels for the y-axes. - title (str): Title for the figure where the clustering results are plotted. + title (str): Title for the figure where the clustering results are + plotted. """ if xlabel is None: xlabel = xlabel_str @@ -321,11 +332,13 @@ def _set_labels(xlabel, ylabel, title, xlabel_str): return xlabel, ylabel, title + def _fig_and_ax_checks(fig, ax): """Checks the arguments *fig* and *ax* passed to the plot functions :func:`plot_cluster_lines ` and - :func:`plot_cluster_bars `. + :func:`plot_cluster_bars + `. In case they are not set yet, they are initialised. Args: @@ -391,22 +404,25 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, the figure is initialized. ax (axis object, optional): axis over where the graph is plotted. If None, see param fig. - sample_colors (list of colors, optional): contains in order the colors of each - sample of the fdatagrid. + sample_colors (list of colors, optional): contains in order the colors + of each sample of the fdatagrid. sample_labels (list of str, optional): contains in order the labels of each sample of the fdatagrid. - cluster_labels (list of str, optional): contains in order the names of each - cluster the samples of the fdatagrid are classified into. - colormap(colormap, optional): colormap from which the colors of the plot are taken. + cluster_labels (list of str, optional): contains in order the names of + each cluster the samples of the fdatagrid are classified into. + colormap(colormap, optional): colormap from which the colors of the + plot are taken. xlabel (str): Label for the x-axis. Defaults to "Sample". ylabel (str): Label for the y-axis. Defaults to "Degree of membership". - title (str, optional): Title for the figure where the clustering results are ploted. + title (str, optional): Title for the figure where the clustering + results are ploted. Defaults to "Degrees of membership of the samples to each cluster". Returns: (tuple): tuple containing: - fig (figure object): figure object in which the graphs are plotted in case ax is None. + fig (figure object): figure object in which the graphs are plotted + in case ax is None. ax (axes object): axes in which the graphs are plotted. @@ -444,12 +460,12 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, cluster_labels = ['${}$'.format(i) for i in range(estimator.n_clusters)] - ax.get_xaxis().set_major_locator(MaxNLocator(integer=True)) for i in range(fdatagrid.nsamples): ax.plot(np.arange(estimator.n_clusters), - estimator.labels_[i], - label=sample_labels[i], color=sample_colors[i]) + estimator.labels_[i], + label=sample_labels[i], + color=sample_colors[i]) ax.set_xticks(np.arange(estimator.n_clusters)) ax.set_xticklabels(cluster_labels) ax.set_xlabel(xlabel) @@ -468,11 +484,11 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, :func:`Fuzzy K-Means ` method. - A kind of barplot is generated in this function with the - membership values obtained from the algorithm. There is a bar for each sample - whose height is 1 (the sum of the membership values of a sample add to 1), and - the part proportional to each cluster is coloured with the corresponding color. - See `Clustering Example <../auto_examples/plot_clustering.html>`_. + A kind of barplot is generated in this function with the membership values + obtained from the algorithm. There is a bar for each sample whose height is + 1 (the sum of the membership values of a sample add to 1), and the part + proportional to each cluster is coloured with the corresponding color. See + `Clustering Example <../auto_examples/plot_clustering.html>`_. Args: estimator (BaseEstimator object): estimator used to calculate the @@ -489,20 +505,23 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, Defaults to -1, in this case, no sorting is done. sample_labels (list of str, optional): contains in order the labels of each sample of the fdatagrid. - cluster_labels (list of str, optional): contains in order the names of each - cluster the samples of the fdatagrid are classified into. + cluster_labels (list of str, optional): contains in order the names of + each cluster the samples of the fdatagrid are classified into. cluster_colors (list of colors): contains in order the colors of each cluster the samples of the fdatagrid are classified into. - colormap(colormap, optional): colormap from which the colors of the plot are taken. + colormap(colormap, optional): colormap from which the colors of the + plot are taken. xlabel (str): Label for the x-axis. Defaults to "Sample". ylabel (str): Label for the y-axis. Defaults to "Degree of membership". - title (str): Title for the figure where the clustering results are plotted. + title (str): Title for the figure where the clustering results are + plotted. Defaults to "Degrees of membership of the samples to each cluster". Returns: (tuple): tuple containing: - fig (figure object): figure object in which the graph is plotted in case ax is None. + fig (figure object): figure object in which the graph is plotted + in case ax is None. ax (axis object): axis in which the graph is plotted. @@ -551,7 +570,6 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, sample_labels = np.copy(sample_labels[sample_indices]) labels_dim = np.copy(estimator.labels_[sample_indices]) - temp_labels = np.copy(labels_dim[:, 0]) labels_dim[:, 0] = labels_dim[:, sort] labels_dim[:, sort] = temp_labels @@ -566,9 +584,9 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, labels_dim = np.concatenate((conc, labels_dim), axis=-1) for i in range(estimator.n_clusters): ax.bar(np.arange(fdatagrid.nsamples), - labels_dim[:, i + 1], - bottom=np.sum(labels_dim[:, :(i + 1)], axis=1), - color=cluster_colors[i]) + labels_dim[:, i + 1], + bottom=np.sum(labels_dim[:, :(i + 1)], axis=1), + color=cluster_colors[i]) ax.set_xticks(np.arange(fdatagrid.nsamples)) ax.set_xticklabels(sample_labels) ax.set_xlabel(xlabel) diff --git a/skfda/exploratory/visualization/magnitude_shape_plot.py b/skfda/exploratory/visualization/magnitude_shape_plot.py index 5fdb11fe9..1cebdbc41 100644 --- a/skfda/exploratory/visualization/magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/magnitude_shape_plot.py @@ -1,7 +1,8 @@ """Magnitude-Shape Plot Module. -This module contains the necessary functions to construct the Magnitude-Shape Plot. -First the directional outlingness is calculated and then, an outliers detection method is implemented. +This module contains the necessary functions to construct the Magnitude-Shape +Plot. First the directional outlingness is calculated and then, an outliers +detection method is implemented. """ @@ -32,7 +33,8 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, The first one, the mean directional outlyingness, describes the relative position (including both distance and direction) of the samples on average - to the center curve; its norm can be regarded as the magnitude outlyingness. + to the center curve; its norm can be regarded as the magnitude + outlyingness. The second one, the variation of the directional outlyingness, measures the change of the directional outlyingness in terms of both norm and @@ -43,45 +45,52 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, .. math:: \mathbf{O}\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right) = - \left\{\frac{1}{d\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right)} - 1\right\} \cdot \mathbf{v}(t) + \left\{\frac{1}{d\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right)} - 1 + \right\} \cdot \mathbf{v}(t) - where :math:`\mathbf{X}` is a stochastic process with probability distribution - :math:`F`, :math:`d` a depth function and :math:`\mathbf{v}(t) = \left\{\mathbf{X}(t) - - \mathbf{Z}(t)\right\} / \lVert \mathbf{X}(t) - \mathbf{Z}(t) \rVert` - is the spatial sign of :math:`\left\{\mathbf{X}(t) - \mathbf{Z}(t)\right\}`, - :math:`\mathbf{Z}(t)` denotes the median and ∥ · ∥ denotes the :math:`L_2` norm. + where :math:`\mathbf{X}` is a stochastic process with probability + distribution :math:`F`, :math:`d` a depth function and :math:`\mathbf{v}(t) + = \left\{ \mathbf{X}(t) - \mathbf{Z}(t)\right\} / \lVert \mathbf{X}(t) - + \mathbf{Z}(t) \rVert` is the spatial sign of :math:`\left\{\mathbf{X}(t) - + \mathbf{Z}(t)\right\}`, :math:`\mathbf{Z}(t)` denotes the median and ∥ · ∥ + denotes the :math:`L_2` norm. From the above formula, we define the mean directional outlyingness as: .. math:: \mathbf{MO}\left(\mathbf{X} , F_{\mathbf{X}}\right) = \int_I - \mathbf{O}\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right) \cdot w(t) dt ; + \mathbf{O}\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right) \cdot w(t) dt; and the variation of the directional outlyingness as: .. math:: - VO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \int_I \lVert\mathbf{O}\left(\mathbf{X}(t) , - F_{\mathbf{X}(t)}\right)-\mathbf{MO}\left(\mathbf{X} , F_{\mathbf{X}}\right) \rVert^2 \cdot w(t) dt + VO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \int_I \lVert\mathbf{O} + \left(\mathbf{X}(t), F_{\mathbf{X}(t)}\right)-\mathbf{MO}\left( + \mathbf{X} , F_{\mathbf{X}}\right) \rVert^2 \cdot w(t) dt - where :math:`w(t)` a weight function defined on the domain of :math:`\mathbf{X}`, :math:`I`. + where :math:`w(t)` a weight function defined on the domain of + :math:`\mathbf{X}`, :math:`I`. Then, the total functional outlyingness can be computed using these values: .. math:: - FO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \lVert \mathbf{MO}\left(\mathbf{X} , - F_{\mathbf{X}}\right)\rVert^2 + VO\left(\mathbf{X} , F_{\mathbf{X}}\right) . + FO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \lVert \mathbf{MO}\left( + \mathbf{X} , F_{\mathbf{X}}\right)\rVert^2 + VO\left(\mathbf{X} , + F_{\mathbf{X}}\right) . Args: - fdatagrid (FDataGrid): Object containing the samples to be ordered according to - the directional outlyingness. - depth_method (:ref:`depth measure `, optional): Method used to - order the data. Defaults to :func:`modified band depth `. - dim_weights (array_like, optional): an array containing the weights of each of - the dimensions of the image. Defaults to the same weight for each of the - dimensions: 1/ndim_image. - pointwise_weights (array_like, optional): an array containing the weights of each - point of discretisation where values have been recorded. Defaults to the same - weight for each of the points: 1/len(interval). + fdatagrid (FDataGrid): Object containing the samples to be ordered + according to the directional outlyingness. + depth_method (:ref:`depth measure `, optional): Method + used to order the data. Defaults to :func:`modified band depth + `. + dim_weights (array_like, optional): an array containing the weights of + each of the dimensions of the image. Defaults to the same weight + for each of the dimensions: 1/ndim_image. + pointwise_weights (array_like, optional): an array containing the + weights of each point of discretisation where values have been + recorded. Defaults to the same weight for each of the points: + 1/len(interval). Returns: (tuple): tuple containing: @@ -89,16 +98,20 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, dir_outlyingness (numpy.array((fdatagrid.shape))): List containing the values of the directional outlyingness of the FDataGrid object. - mean_dir_outl (numpy.array((fdatagrid.nsamples, 2))): List containing - the values of the magnitude outlyingness for each of the samples. + mean_dir_outl (numpy.array((fdatagrid.nsamples, 2))): List + containing the values of the magnitude outlyingness for each of + the samples. variation_dir_outl (numpy.array((fdatagrid.nsamples,))): List - containing the values of the shape outlyingness for each of the samples. + containing the values of the shape outlyingness for each of + the samples. Example: - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> directional_outlyingness(fd) @@ -131,7 +144,8 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, [-1. ]]]), array([[ 1.66666667], [ 0. ], [-0.73333333], - [-1. ]]), array([ 0.74074074, 0. , 0.36740741, 0.53333333])) + [-1. ]]), array([ 0.74074074, 0. , 0.36740741, + 0.53333333])) """ @@ -145,11 +159,12 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, "There must be a weight in dim_weights for each dimension of the " "image and altogether must sum 1.") - if pointwise_weights is not None and (len( - pointwise_weights) != fdatagrid.ncol or pointwise_weights.sum() != 1): + if (pointwise_weights is not None and + (len(pointwise_weights) != fdatagrid.ncol or + pointwise_weights.sum() != 1)): raise ValueError( - "There must be a weight in pointwise_weights for each recorded time " - "point and altogether must sum 1.") + "There must be a weight in pointwise_weights for each recorded " + "time point and altogether must sum 1.") depth, depth_pointwise = depth_method(fdatagrid, pointwise=True) @@ -164,7 +179,8 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, weighted_depth = depth * dim_weights sample_depth = weighted_depth.sum(axis=-1) - # Obtaining the median sample Z, to caculate v(t) = {X(t) − Z(t)}/∥ X(t) − Z(t)∥ + # Obtaining the median sample Z, to caculate + # v(t) = {X(t) − Z(t)}/∥ X(t) − Z(t)∥ median_index = np.argmax(sample_depth) median = fdatagrid.data_matrix[median_index] v = fdatagrid.data_matrix - median @@ -187,7 +203,8 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, (fdatagrid.ndim_image, 1)).T weighted_dir_outlyingness = dir_outlyingness * pointwise_weights_1 mean_dir_outl = scipy.integrate.simps(weighted_dir_outlyingness, - fdatagrid.sample_points[0], axis = 1) + fdatagrid.sample_points[0], + axis=1) # Calcuation variation directinal outlyingness mean_dir_outl_pointwise = np.repeat(mean_dir_outl, fdatagrid.ncol, @@ -196,24 +213,26 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, la.norm(dir_outlyingness - mean_dir_outl_pointwise, axis=-1)) weighted_norm = norm * pointwise_weights variation_dir_outl = scipy.integrate.simps(weighted_norm, - fdatagrid.sample_points[0], axis=1) + fdatagrid.sample_points[0], + axis=1) return dir_outlyingness, mean_dir_outl, variation_dir_outl + class MagnitudeShapePlot: r"""Implementation of the magnitude-shape plot - This plot, which is based on the calculation of the - :func:`directional outlyingness ` + This plot, which is based on the calculation of the :func:`directional + outlyingness ` of each of the samples, serves as a visualization tool for the centrality of curves. Furthermore, an outlier detection procedure is included. - The norm of the mean of the directional outlyingness (:math:`\lVert\mathbf{MO}\rVert`) - is plotted in the x-axis, and the variation of the directional outlyingness (:math:`VO`) - in the y-axis. + The norm of the mean of the directional outlyingness (:math:`\lVert + \mathbf{MO}\rVert`) is plotted in the x-axis, and the variation of the + directional outlyingness (:math:`VO`) in the y-axis. - Considering :math:`\mathbf{Y} = \left(\mathbf{MO}^T, VO\right)^T`, the outlier detection method - is implemented as described below. + Considering :math:`\mathbf{Y} = \left(\mathbf{MO}^T, VO\right)^T`, the + outlier detection method is implemented as described below. First, the square robust Mahalanobis distance is calculated based on a sample of size :math:`h \leq fdatagrid.nsamples`: @@ -224,11 +243,11 @@ class MagnitudeShapePlot: \left( \mathbf{Y} - \mathbf{\tilde{Y}}^*_J\right) where :math:`J` denotes the group of :math:`h` samples that minimizes the - determinant of the corresponding covariance matrix, :math:`\mathbf{\tilde{Y}}^*_J - = h^{-1}\sum_{i\in{J}}\mathbf{Y}_i` and :math:`\mathbf{S}^*_J - = h^{-1}\sum_{i\in{J}}\left( \mathbf{Y}_i - \mathbf{\tilde{Y}}^*_J\right) - \left( \mathbf{Y}_i - \mathbf{\tilde{Y}}^*_J\right)^T`. The - sub-sample of size h controls the robustness of the method. + determinant of the corresponding covariance matrix, + :math:`\mathbf{\tilde{Y}}^*_J = h^{-1}\sum_{i\in{J}}\mathbf{Y}_i` and + :math:`\mathbf{S}^*_J = h^{-1}\sum_{i\in{J}}\left( \mathbf{Y}_i - \mathbf{ + \tilde{Y}}^*_J\right) \left( \mathbf{Y}_i - \mathbf{\tilde{Y}}^*_J + \right)^T`. The sub-sample of size h controls the robustness of the method. Then, the tail of this distance distribution is approximated as follows: @@ -237,8 +256,8 @@ class MagnitudeShapePlot: \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right)\sim F_{p+1, m-p} where :math:`p` is the dmension of the image, and :math:`c` and :math:`m` - are parameters determining the degrees of freedom of the :math:`F`-distribution - and the scaling factor. + are parameters determining the degrees of freedom of the + :math:`F`-distribution and the scaling factor. .. math:: c = E \left[s^*_{jj}\right] @@ -248,12 +267,13 @@ class MagnitudeShapePlot: .. math:: m = \frac{2}{CV^2} - where :math:`CV` is the estimated coefficient of variation of the diagonal elements of the MCD shape estimator. + where :math:`CV` is the estimated coefficient of variation of the diagonal + elements of the MCD shape estimator. Finally, we choose a cutoff value to determine the outliers, C , as the α quantile of :math:`F_{p+1, m-p}`. We set :math:`\alpha = 0.993`, - which is used in the classical boxplot for detecting outliers under a normal - distribution. + which is used in the classical boxplot for detecting outliers under a + normal distribution. Attributes: fdatagrid (FDataGrid): Object to be visualized. @@ -273,11 +293,12 @@ class MagnitudeShapePlot: outliers (1-D array, (fdatagrid.nsamples,)): Contains 1 or 0 to denote if a sample is an outlier or not, respecively. colormap(matplotlib.pyplot.LinearSegmentedColormap, optional): Colormap - from which the colors of the plot are extracted. Defaults to 'seismic'. + from which the colors of the plot are extracted. Defaults to + 'seismic'. color (float, optional): Tone of the colormap in which the nonoutlier points are plotted. Defaults to 0.2. - outliercol (float, optional): Tone of the colormap in which the outliers - are plotted. Defaults to 0.8. + outliercol (float, optional): Tone of the colormap in which the + outliers are plotted. Defaults to 0.8. xlabel (string, optional): Label of the x-axis. Defaults to 'MO', mean of the directional outlyingness. ylabel (string, optional): Label of the y-axis. Defaults to 'VO', @@ -286,8 +307,10 @@ class MagnitudeShapePlot: Example: - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> MagnitudeShapePlot(fd) @@ -325,7 +348,8 @@ class MagnitudeShapePlot: dataset_label=None, axes_labels=None, extrapolation=None, - interpolator=SplineInterpolator(interpolation_order=1, smoothness_parameter=0.0, monotone=False), + interpolator=SplineInterpolator(interpolation_order=1, + smoothness_parameter=0.0, monotone=False), keepdims=False), depth_method=modified_band_depth, dim_weights=None, @@ -346,31 +370,32 @@ class MagnitudeShapePlot: def __init__(self, fdatagrid, depth_method=modified_band_depth, dim_weights=None, pointwise_weights=None, alpha=0.993, - assume_centered=False, support_fraction=None,random_state=0): + assume_centered=False, support_fraction=None, random_state=0): """Initialization of the MagnitudeShapePlot class. Args: fdatagrid (FDataGrid): Object containing the data. - depth_method (:ref:`depth measure `, optional): Method - used to order the data. Defaults to :func:`modified band depth - `. + depth_method (:ref:`depth measure `, optional): + Method used to order the data. Defaults to :func:`modified band + depth `. dim_weights (array_like, optional): an array containing the weights of each of the dimensions of the image. pointwise_weights (array_like, optional): an array containing the - weights of each points of discretisati on where values have been - recorded. + weights of each points of discretisati on where values have + been recorded. alpha (float, optional): Denotes the quantile to choose the cutoff value for detecting outliers Defaults to 0.993, which is used in the classical boxplot. - assume_centered (boolean, optional): If True, the support of the robust - location and the covariance estimates is computed, and a + assume_centered (boolean, optional): If True, the support of the + robust location and the covariance estimates is computed, and a covariance estimate is recomputed from it, without centering the data. Useful to work with data whose mean is significantly equal to zero but is not exactly zero. If False, default value, the robust location and covariance are directly computed with the FastMCD algorithm without additional treatment. - support_fraction (float, 0 < support_fraction < 1, optional): The proportion - of points to be included in the support of the raw MCD estimate. + support_fraction (float, 0 < support_fraction < 1, optional): The + proportion of points to be included in the support of the + raw MCD estimate. Default is None, which implies that the minimum value of support_fraction will be used within the algorithm: [n_sample + n_features + 1] / 2 @@ -395,7 +420,8 @@ def __init__(self, fdatagrid, depth_method=modified_band_depth, points = np.array(list(zip(mean_dir_outl, variation_dir_outl))).astype( float) - # The square mahalanobis distances of the samples are calulated using MCD. + # The square mahalanobis distances of the samples are + # calulated using MCD. cov = MinCovDet(store_precision=False, assume_centered=assume_centered, support_fraction=support_fraction, random_state=random_state).fit(points) @@ -410,7 +436,8 @@ def __init__(self, fdatagrid, depth_method=modified_band_depth, dfn = p + 1 dfd = m - p - # Calculation of the cutoff value and scaling factor to identify outliers. + # Calculation of the cutoff value and scaling factor to identify + # outliers. cutoff_value = f.ppf(alpha, dfn, dfd, loc=0, scale=1) scaling = c * dfd / m / dfn outliers = (scaling * rmd_2 > cutoff_value) * 1 @@ -466,8 +493,8 @@ def colormap(self): @colormap.setter def colormap(self, value): if not isinstance(value, matplotlib.colors.LinearSegmentedColormap): - raise ValueError( - "colormap must be of type matplotlib.colors.LinearSegmentedColormap") + raise ValueError("colormap must be of type " + "matplotlib.colors.LinearSegmentedColormap") self._colormap = value @property From 32f4226f282847ac272d57068992d848e0f3e11f Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 21:27:24 +0200 Subject: [PATCH 098/222] Fixed PEP8 on misc --- skfda/misc/_lfd.py | 2 +- skfda/misc/_math.py | 5 +- skfda/misc/covariances.py | 2 +- skfda/misc/metrics.py | 104 ++++++++++++++++++++------------------ 4 files changed, 58 insertions(+), 55 deletions(-) diff --git a/skfda/misc/_lfd.py b/skfda/misc/_lfd.py index e48e7d12e..e52478487 100644 --- a/skfda/misc/_lfd.py +++ b/skfda/misc/_lfd.py @@ -59,7 +59,7 @@ def __init__(self, order=None, weights=None, domain_range=(0, 1)): i in range(order + 1)] else: - if len(weights) is 0: + if len(weights) == 0: raise ValueError("You have to provide one weight at least") if all(isinstance(n, int) for n in weights): diff --git a/skfda/misc/_math.py b/skfda/misc/_math.py index 521fe2dc1..8891316d3 100644 --- a/skfda/misc/_math.py +++ b/skfda/misc/_math.py @@ -6,7 +6,6 @@ """ import numpy as np import scipy.integrate -from ..exploratory.stats import mean __author__ = "Miguel Carbajo Berrocal" @@ -132,7 +131,7 @@ def cumsum(fdatagrid): """ return fdatagrid.copy(data_matrix=np.cumsum(fdatagrid.data_matrix, - axis=0)) + axis=0)) def inner_product(fdatagrid, fdatagrid2): @@ -185,7 +184,7 @@ def inner_product(fdatagrid, fdatagrid2): "one.") # Checks if not np.array_equal(fdatagrid.sample_points, - fdatagrid2.sample_points): + fdatagrid2.sample_points): raise ValueError("Sample points for both objects must be equal") # Creates an empty matrix with the desired size to store the results. diff --git a/skfda/misc/covariances.py b/skfda/misc/covariances.py index d7de49f65..d21e11f6c 100644 --- a/skfda/misc/covariances.py +++ b/skfda/misc/covariances.py @@ -39,7 +39,7 @@ def _execute_covariance(covariance, x, y): class Brownian(): """Brownian covariance""" - def __init__(self, *, variance: float=1., origin: float=0.): + def __init__(self, *, variance: float = 1., origin: float = 0.): self.variance = variance self.origin = origin diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 2fe55e8f8..6cd587a63 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -10,8 +10,8 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None): - r"""Checks if the fdatas passed as argument are unidimensional and compatible - and converts them to FDatagrid to compute their distances. + r"""Checks if the fdatas passed as argument are unidimensional and + compatible and converts them to FDatagrid to compute their distances. Args: @@ -24,7 +24,7 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None): # To allow use numpy arrays internally if (not isinstance(fdata1, FData) and not isinstance(fdata2, FData) - and eval_points is not None): + and eval_points is not None): fdata1 = FDataGrid([fdata1], sample_points=eval_points) fdata2 = FDataGrid([fdata1], sample_points=eval_points) @@ -50,23 +50,25 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None): elif not isinstance(fdata2, FDataGrid) and isinstance(fdata1, FDataGrid): fdata2 = fdata2.to_grid(fdata1.eval_points) - elif not isinstance(fdata1, FDataGrid) and not isinstance(fdata2, FDataGrid): + elif (not isinstance(fdata1, FDataGrid) and + not isinstance(fdata2, FDataGrid)): fdata1 = fdata1.to_grid(eval_points) fdata2 = fdata2.to_grid(eval_points) elif not np.array_equal(fdata1.sample_points, - fdata2.sample_points): + fdata2.sample_points): raise ValueError("Sample points for both objects must be equal or" "a new list evaluation points must be specified") return fdata1, fdata2 + def vectorial_norm(fdatagrid, p=2): r"""Apply a vectorial norm to a multivariate function. - Given a multivariate function :math:`f:\mathbb{R}^n\rightarrow \mathbb{R}^d` - applies a vectorial norm :math:`\| \cdot \|` to produce a function - :math:`\|f\|:\mathbb{R}^n\rightarrow \mathbb{R}`. + Given a multivariate function :math:`f:\mathbb{R}^n\rightarrow + \mathbb{R}^d` applies a vectorial norm :math:`\| \cdot \|` to produce a + function :math:`\|f\|:\mathbb{R}^n\rightarrow \mathbb{R}`. For example, let :math:`f:\mathbb{R} \rightarrow \mathbb{R}^2` be :math:`f(t)=(f_1(t), f_2(t))` and :math:`\| \cdot \|_2` the euclidian norm. @@ -111,7 +113,7 @@ def vectorial_norm(fdatagrid, p=2): """ data_matrix = np.linalg.norm(fdatagrid.data_matrix, ord=p, axis=-1, - keepdims=True) + keepdims=True) return fdatagrid.copy(data_matrix=data_matrix) @@ -120,8 +122,8 @@ def distance_from_norm(norm, **kwargs): r"""Returns the distance induced by a norm. Given a norm :math:`\| \cdot \|: X \rightarrow \mathbb{R}`, - returns the distance :math:`d: X \times X \rightarrow \mathbb{R}` induced by - the norm: + returns the distance :math:`d: X \times X \rightarrow \mathbb{R}` induced + by the norm: .. math:: d(f,g) = \|f - g\| @@ -137,8 +139,8 @@ def distance_from_norm(norm, **kwargs): Examples: Computes the :math:`\mathbb{L}^2` distance between an object containing functional data corresponding to the function :math:`y(x) = x` defined - over the interval [0, 1] and another one containing data of the function - :math:`y(x) = x/2`. + over the interval [0, 1] and another one containing data of the + function :math:`y(x) = x/2`. Firstly we create the functional data. @@ -163,6 +165,7 @@ def norm_distance(fdata1, fdata2): return norm_distance + def pairwise_distance(distance, **kwargs): r"""Return pairwise distance for FDataGrid objects. @@ -183,8 +186,8 @@ def pairwise_distance(distance, **kwargs): to the distance function. Returns: - :obj:`Function`: Pairwise distance function, wich accepts two functional - data objects and returns the pairwise distance matrix. + :obj:`Function`: Pairwise distance function, wich accepts two + functional data objects and returns the pairwise distance matrix. """ def pairwise(fdata1, fdata2): @@ -278,7 +281,7 @@ def norm_lp(fdatagrid, p=2, p2=2): if fdatagrid.ndim_image > 1: data_matrix = np.linalg.norm(fdatagrid.data_matrix, ord=p2, axis=-1, - keepdims=True) + keepdims=True) else: data_matrix = np.abs(fdatagrid.data_matrix) @@ -343,14 +346,12 @@ def lp_distance(fdata1, fdata2, p=2, *, eval_points=None): return norm_lp(fdata1 - fdata2, p=p) - - def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): r"""Compute the Fisher-Rao distance btween two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let - :math:`q_i` and :math:`q_j` be the corresponding SRSF (see :func:`to_srsf`), - the fisher rao distance is defined as + :math:`q_i` and :math:`q_j` be the corresponding SRSF + (see :func:`to_srsf`), the fisher rao distance is defined as .. math:: d_{FR}(f_i, f_j) = \| q_i - q_j \|_2 = @@ -389,9 +390,9 @@ def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): # Calculate the corresponding srsf and normalize to (0,1) fdata1 = fdata1.copy(sample_points=eval_points_normalized, - domain_range=(0,1)) + domain_range=(0, 1)) fdata2 = fdata2.copy(sample_points=eval_points_normalized, - domain_range=(0,1)) + domain_range=(0, 1)) fdata1_srsf = to_srsf(fdata1) fdata2_srsf = to_srsf(fdata2) @@ -399,12 +400,13 @@ def fisher_rao_distance(fdata1, fdata2, *, eval_points=None): # Return the L2 distance of the SRSF return lp_distance(fdata1_srsf, fdata2_srsf, p=2) + def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): r"""Compute the amplitude distance between two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let - :math:`q_i` and :math:`q_j` be the corresponding SRSF (see :func:`to_srsf`), - the amplitude distance is defined as + :math:`q_i` and :math:`q_j` be the corresponding SRSF + (see :func:`to_srsf`), the amplitude distance is defined as .. math:: d_{A}(f_i, f_j)=min_{\gamma \in \Gamma}d_{FR}(f_i \circ \gamma,f_j) @@ -443,9 +445,9 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): ValueError: If the objects are not unidimensional. Refereces: - .. [SK16-4-10-1] Srivastava, Anuj & Klassen, Eric P. (2016). Functional - and shape data analysis. In *Amplitude Space and a Metric Structure* - (pp. 107-109). Springer. + .. [SK16-4-10-1] Srivastava, Anuj & Klassen, Eric P. (2016). + Functional and shape data analysis. In *Amplitude Space and a + Metric Structure* (pp. 107-109). Springer. """ fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points) @@ -455,20 +457,20 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): # Calculate the corresponding srsf and normalize to (0,1) fdata1 = fdata1.copy(sample_points=eval_points_normalized, - domain_range=(0,1)) + domain_range=(0, 1)) fdata2 = fdata2.copy(sample_points=eval_points_normalized, - domain_range=(0,1)) + domain_range=(0, 1)) fdata1_srsf = to_srsf(fdata1) fdata2_srsf = to_srsf(fdata2) warping = elastic_registration_warping(fdata1, - template=fdata2, - lam=lam, - eval_points=eval_points_normalized, - fdatagrid_srsf=fdata1_srsf, - template_srsf=fdata2_srsf, - **kwargs) + template=fdata2, + lam=lam, + val_points=eval_points_normalized, + fdatagrid_srsf=fdata1_srsf, + template_srsf=fdata2_srsf, + **kwargs) fdata1_reg = fdata1.compose(warping) @@ -487,6 +489,7 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): return distance + def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): r"""Compute the amplitude distance btween two functional objects. @@ -521,9 +524,9 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): Refereces: - .. [SK16-4-10-2] Srivastava, Anuj & Klassen, Eric P. (2016). Functional - and shape data analysis. In *Phase Space and a Metric Structure* - (pp. 109-111). Springer. + .. [SK16-4-10-2] Srivastava, Anuj & Klassen, Eric P. (2016). + Functional and shape data analysis. In *Phase Space and a Metric + Structure* (pp. 109-111). Springer. """ @@ -534,14 +537,15 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): # Calculate the corresponding srsf and normalize to (0,1) fdata1 = fdata1.copy(sample_points=eval_points_normalized, - domain_range=(0,1)) + domain_range=(0, 1)) fdata2 = fdata2.copy(sample_points=eval_points_normalized, - domain_range=(0,1)) + domain_range=(0, 1)) - warping = elastic_registration_warping(fdata1, template=fdata2, - lam=lam, - eval_points=eval_points_normalized, - **kwargs) + warping = elastic_registration_warping(fdata1, + template=fdata2, + lam=lam, + eval_points=eval_points_normalized, + **kwargs) derivative_warping = warping(eval_points_normalized, keepdims=False, derivative=1)[0] @@ -583,9 +587,9 @@ def warping_distance(warping1, warping2, *, eval_points=None): ValueError: If the objects are not unidimensional. Refereces: - .. [SK16-4-11-2] Srivastava, Anuj & Klassen, Eric P. (2016). Functional - and shape data analysis. In *Probability Density Functions* - (pp. 113-117). Springer. + .. [SK16-4-11-2] Srivastava, Anuj & Klassen, Eric P. (2016). + Functional and shape data analysis. In *Probability Density + Functions* (pp. 113-117). Springer. """ @@ -593,13 +597,13 @@ def warping_distance(warping1, warping2, *, eval_points=None): eval_points=eval_points) # Normalization of warping to (0,1)x(0,1) - warping1 = normalize_warping(warping1, (0,1)) - warping2 = normalize_warping(warping2, (0,1)) + warping1 = normalize_warping(warping1, (0, 1)) + warping2 = normalize_warping(warping2, (0, 1)) warping1_data = warping1.derivative().data_matrix[0, ..., 0] warping2_data = warping2.derivative().data_matrix[0, ..., 0] - # In this case the srsf is the sqrt(gamma') + # In this case the srsf is the sqrt(gamma') srsf_warping1 = np.sqrt(warping1_data, out=warping1_data) srsf_warping2 = np.sqrt(warping2_data, out=warping2_data) From 72c030a49f0c18a1d080b69aabff28ddd9b4f65a Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 21:50:46 +0200 Subject: [PATCH 099/222] Fixed PEP8 on ml --- skfda/ml/clustering/base_kmeans.py | 350 ++++++++++++++++------------- 1 file changed, 194 insertions(+), 156 deletions(-) diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index cc0bba08e..ac4be370e 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -27,26 +27,28 @@ def __init__(self, n_clusters, init, metric, n_init, max_iter, tol, """Initialization of the BaseKMeans class. Args: - n_clusters (int, optional): Number of groups into which the samples are - classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the different - clusters the algorithm starts with. Its data_marix must be of - the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). - Defaults to None, and the centers are initialized randomly. - metric (optional): metric that acceps two FDataGrid objects and returns - a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). - Defaults to *pairwise_distance(lp_distance)*. - n_init (int, optional): Number of time the k-means algorithm will be - run with different centroid seeds. The final results will be the - best output of n_init consecutive runs in terms of inertia. + n_clusters (int, optional): Number of groups into which the samples + are classified. Defaults to 2. + init (FDataGrid, optional): Contains the initial centers of the + different clusters the algorithm starts with. Its data_marix + must be of the shape (n_clusters, fdatagrid.ncol, + fdatagrid.ndim_image). Defaults to None, and the centers are + initialized randomly. + metric (optional): metric that acceps two FDataGrid objects and + returns a matrix with shape (fdatagrid1.nsamples, + fdatagrid2.nsamples). Defaults to *pairwise_distance(lp_distance)*. + n_init (int, optional): Number of time the k-means algorithm will + be run with different centroid seeds. The final results will be the + best output of n_init consecutive runs in terms of inertia. max_iter (int, optional): Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. tol (float, optional): tolerance used to compare the centroids calculated with the previous ones in every single run of the algorithm. random_state (int, RandomState instance or None, optional): - Determines random number generation for centroid initialization. ç - Use an int to make the randomness deterministic. Defaults to 0. + Determines random number generation for centroid + initialization. ç Use an int to make the randomness + deterministic. Defaults to 0. See :term:`Glossary `. """ self.n_clusters = n_clusters @@ -89,9 +91,9 @@ def _generic_clustering_checks(self, fdatagrid): if self.init is not None and self.init.shape != ( self.n_clusters, fdatagrid.ncol, fdatagrid.ndim_image): - raise ValueError( - "The init FDataGrid data_matrix should be of shape (n_clusters, " - "n_features, ndim_image) and gives the initial centers.") + raise ValueError("The init FDataGrid data_matrix should be of " + "shape (n_clusters, n_features, ndim_image) and " + "gives the initial centers.") if self.max_iter < 1: raise ValueError( @@ -254,84 +256,96 @@ class KMeans(BaseKMeans): r"""Representation and implementation of the K-Means algorithm for the FdataGrid object. - Let :math:`\mathbf{X = \left\{ x_{1}, x_{2}, ..., x_{n}\right\}}` be a given dataset to be - analyzed, and :math:`\mathbf{V = \left\{ v_{1}, v_{2}, ..., v_{c}\right\}}` be the set of - centers of clusters in :math:`\mathbf{X}` dataset in :math:`m` dimensional space - :math:`\left(\mathbb{R}^m \right)`. Where :math:`n` is the number of objects, :math:`m` is the - number of features, and :math:`c` is the number of partitions or clusters. + Let :math:`\mathbf{X = \left\{ x_{1}, x_{2}, ..., x_{n}\right\}}` be a + given dataset to be analyzed, and :math:`\mathbf{V = \left\{ v_{1}, v_{2}, + ..., v_{c}\right\}}` be the set of centers of clusters in + :math:`\mathbf{X}` dataset in :math:`m` dimensional space :math:`\left( + \mathbb{R}^m \right)`. Where :math:`n` is the number of objects, :math:`m` + is the number of features, and :math:`c` is the number of partitions or + clusters. - KM iteratively computes cluster centroids in order to minimize the sum with respect to the specified - measure. KM algorithm aims at minimizing an objective function known as the squared error function given - as follows: + KM iteratively computes cluster centroids in order to minimize the sum with + respect to the specified measure. KM algorithm aims at minimizing an + objective function known as the squared error function given as follows: .. math:: - J_{KM}\left(\mathbf{X}; \mathbf{V}\right) = \sum_{i=1}^{c}\sum_{j=1}^{n}D_{ij}^2 - - Where, :math:`D_{ij}^2` is the squared chosen distance measure which can be any p-norm: - :math:`D_{ij} = \lVert x_{ij} - v_{i} \rVert = \left( \int_I \lvert x_{ij} - v_{i}\rvert^p dx \right)^{ \frac{1}{p}}`, - being :math:`I` the domain where :math:`\mathbf{X}` is defined, :math:`1 \leqslant i \leqslant c`, - :math:`1 \leqslant j\leqslant n_{i}`. Where :math:`n_{i}` represents the number of data points in i-th cluster. - - For :math:`c` clusters, KM is based on an iterative algorithm minimizing the sum of distances from each - observation to its cluster centroid. The observations are moved between clusters until the sum cannot be decreased + J_{KM}\left(\mathbf{X}; \mathbf{V}\right) = \sum_{i=1}^{c} + \sum_{j=1}^{n}D_{ij}^2 + + Where, :math:`D_{ij}^2` is the squared chosen distance measure which can + be any p-norm: :math:`D_{ij} = \lVert x_{ij} - v_{i} \rVert = \left( + \int_I \lvert x_{ij} - v_{i}\rvert^p dx \right)^{ \frac{1}{p}}`, being + :math:`I` the domain where :math:`\mathbf{X}` is defined, :math:`1 + \leqslant i \leqslant c`, :math:`1 \leqslant j\leqslant n_{i}`. Where + :math:`n_{i}` represents the number of data points in i-th cluster. + + For :math:`c` clusters, KM is based on an iterative algorithm minimizing + the sum of distances from each observation to its cluster centroid. The + observations are moved between clusters until the sum cannot be decreased any more. KM algorithm involves the following steps: - 1. Centroids of :math:`c` clusters are chosen from :math:`\mathbf{X}` randomly or are passed to the - function as a parameter. + 1. Centroids of :math:`c` clusters are chosen from :math:`\mathbf{X}` + randomly or are passed to the function as a parameter. 2. Distances between data points and cluster centroids are calculated. - 3. Each data point is assigned to the cluster whose centroid is closest to it. + 3. Each data point is assigned to the cluster whose centroid is + closest to it. 4. Cluster centroids are updated by using the following formula: - :math:`\mathbf{v_{i}} ={\sum_{i=1}^{n_{i}}x_{ij}}/n_{i}` :math:`1 \leqslant i \leqslant c`. + :math:`\mathbf{v_{i}} ={\sum_{i=1}^{n_{i}}x_{ij}}/n_{i}` :math:`1 + \leqslant i \leqslant c`. 5. Distances from the updated cluster centroids are recalculated. - 6. If no data point is assigned to a new cluster the run of algorithm is stopped, otherwise the - steps from 3 to 5 are repeated for probable movements of data points between the clusters. + 6. If no data point is assigned to a new cluster the run of algorithm is + stopped, otherwise the steps from 3 to 5 are repeated for probable + movements of data points between the clusters. - This algorithm is applied for each dimension on the image of the FDataGrid object. + This algorithm is applied for each dimension on the image of the FDataGrid + object. Args: - n_clusters (int, optional): Number of groups into which the samples are + n_clusters (int, optional): Number of groups into which the samples are classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the different - clusters the algorithm starts with. Its data_marix must be of - the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). + init (FDataGrid, optional): Contains the initial centers of the + different clusters the algorithm starts with. Its data_marix must + be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. - metric (optional): metric that acceps two FDataGrid objects and returns - a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). + metric (optional): metric that acceps two FDataGrid objects and returns + a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). Defaults to *pairwise_distance(lp_distance)*. - n_init (int, optional): Number of time the k-means algorithm will be - run with different centroid seeds. The final results will be the + n_init (int, optional): Number of time the k-means algorithm will be + run with different centroid seeds. The final results will be the best output of n_init consecutive runs in terms of inertia. - max_iter (int, optional): Maximum number of iterations of the + max_iter (int, optional): Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. - tol (float, optional): tolerance used to compare the centroids - calculated with the previous ones in every single run of the + tol (float, optional): tolerance used to compare the centroids + calculated with the previous ones in every single run of the algorithm. - random_state (int, RandomState instance or None, optional): - Determines random number generation for centroid initialization. ç + random_state (int, RandomState instance or None, optional): + Determines random number generation for centroid initialization. Use an int to make the randomness deterministic. Defaults to 0. See :term:`Glossary `. - + Attributes: - labels_ (numpy.ndarray: (nsamples, ndim_image)): 2-dimensional matrix + labels_ (numpy.ndarray: (nsamples, ndim_image)): 2-dimensional matrix in which each row contains the cluster that observation belongs to. - cluster_centers_ (FDataGrid object): data_matrix of shape - (n_clusters, ncol, ndim_image) and contains the centroids for + cluster_centers_ (FDataGrid object): data_matrix of shape + (n_clusters, ncol, ndim_image) and contains the centroids for each cluster. - inertia_ (numpy.ndarray, (fdatagrid.ndim_image)): Sum of squared - distances of samples to their closest cluster center for each + inertia_ (numpy.ndarray, (fdatagrid.ndim_image)): Sum of squared + distances of samples to their closest cluster center for each dimension. - n_iter_ (numpy.ndarray, (fdatagrid.ndim_image)): number of iterations the - algorithm was run for each dimension. + n_iter_ (numpy.ndarray, (fdatagrid.ndim_image)): number of iterations + the algorithm was run for each dimension. Example: - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> kmeans = KMeans() @@ -340,7 +354,8 @@ class KMeans(BaseKMeans): >>> kmeans.fit(fd, init=init_fd) >>> kmeans KMeans(max_iter=100, - metric=.pairwise at 0x7faf3aa061e0>, # doctest:+ELLIPSIS + metric=.pairwise + at 0x7faf3aa061e0>, # doctest:+ELLIPSIS n_clusters=2, random_state=0, tol=0.0001) """.replace('+IGNORE_RESULT', '+ELLIPSIS\n<...>') @@ -350,26 +365,31 @@ def __init__(self, n_clusters=2, init=None, """Initialization of the KMeans class. Args: - n_clusters (int, optional): Number of groups into which the samples are - classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the different - clusters the algorithm starts with. Its data_marix must be of - the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). - Defaults to None, and the centers are initialized randomly. - metric (optional): metric that acceps two FDataGrid objects and returns - a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). + n_clusters (int, optional): Number of groups into which the samples + are classified. Defaults to 2. + init (FDataGrid, optional): Contains the initial centers of the + different clusters the algorithm starts with. Its data_marix + must be of the shape (n_clusters, fdatagrid.ncol, + fdatagrid.ndim_image). Defaults to None, and the centers are + initialized randomly. + metric (optional): metric that acceps two FDataGrid objects and + returns a matrix with shape (fdatagrid1.nsamples, + fdatagrid2.nsamples). Defaults to *pairwise_distance(lp_distance)*. - n_init (int, optional): Number of time the k-means algorithm will be - run with different centroid seeds. The final results will be the - best output of n_init consecutive runs in terms of inertia. + n_init (int, optional): Number of time the k-means algorithm will + be run with different centroid seeds. The final results will + be the best output of n_init consecutive runs in terms + of inertia. max_iter (int, optional): Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. tol (float, optional): tolerance used to compare the centroids calculated with the previous ones in every single run of the algorithm. random_state (int, RandomState instance or None, optional): - Determines random number generation for centroid initialization. - Use an int to make the randomness deterministic. Defaults to 0. + Determines random number generation for centroid + initialization. Use an int to make the randomness + deterministic. + Defaults to 0. """ super().__init__(n_clusters=n_clusters, init=init, metric=metric, n_init=n_init, max_iter=max_iter, tol=tol, @@ -449,10 +469,10 @@ def fit(self, X, y=None, sample_weight=None): n_iter = np.empty((self.n_init)) for j in range(self.n_init): - clustering_values[j, :], centers[j, :, :, :], \ - distances_to_centers[j, :, :], n_iter[j] = \ + (clustering_values[j, :], centers[j, :, :, :], + distances_to_centers[j, :, :], n_iter[j]) = ( self._kmeans_implementation(fdatagrid=fdatagrid, - random_state=random_state) + random_state=random_state)) distances_to_their_center[j, :] = distances_to_centers[ j, np.arange(fdatagrid.nsamples), clustering_values[j, :]] @@ -461,8 +481,10 @@ def fit(self, X, y=None, sample_weight=None): index_best_iter = np.argmin(inertia) self.labels_ = clustering_values[index_best_iter] - self.cluster_centers_ = FDataGrid(data_matrix=centers[index_best_iter], - sample_points=fdatagrid.sample_points) + self.cluster_centers_ = FDataGrid( + data_matrix=centers[index_best_iter], + sample_points=fdatagrid.sample_points + ) self._distances_to_centers = distances_to_centers[index_best_iter] self.inertia_ = inertia[index_best_iter] self.n_iter_ = n_iter[index_best_iter] @@ -474,88 +496,99 @@ class FuzzyKMeans(BaseKMeans): r""" Representation and implementation of the Fuzzy K-Means clustering algorithm for the FDataGrid object. - Let :math:`\mathbf{X = \left\{ x_{1}, x_{2}, ..., x_{n}\right\}}` be a given dataset to be - analyzed, and :math:`\mathbf{V = \left\{ v_{1}, v_{2}, ..., v_{c}\right\}}` be the set of - centers of clusters in :math:`\mathbf{X}` dataset in :math:`m` dimensional space - :math:`\left(\mathbb{R}^m \right)`. Where :math:`n` is the number of objects, :math:`m` is the - number of features, and :math:`c` is the number of partitions or clusters. + Let :math:`\mathbf{X = \left\{ x_{1}, x_{2}, ..., x_{n}\right\}}` be a + given dataset to be analyzed, and :math:`\mathbf{V = \left\{ v_{1}, v_{2}, + ..., v_{c}\right\}}` be the set of centers of clusters in + :math:`\mathbf{X}` dataset in :math:`m` dimensional space :math:`\left( + \mathbb{R}^m \right)`. Where :math:`n` is the number of objects, :math:`m` + is the number of features, and :math:`c` is the number of partitions + or clusters. FCM minimizes the following objective function: .. math:: - J_{FCM}\left(\mathbf{X}; \mathbf{U, V}\right) = \sum_{i=1}^{c}\sum_{j=1}^{n}u_{ij}^{f}D_{ij}^2. + J_{FCM}\left(\mathbf{X}; \mathbf{U, V}\right) = \sum_{i=1}^{c} + \sum_{j=1}^{n}u_{ij}^{f}D_{ij}^2. - This function differs from classical KM with the use of weighted squared errors instead of using squared - errors only. In the objective function, :math:`\mathbf{U}` is a fuzzy partition matrix that is computed from + This function differs from classical KM with the use of weighted squared + errors instead of using squared errors only. In the objective function, + :math:`\mathbf{U}` is a fuzzy partition matrix that is computed from dataset :math:`\mathbf{X}`: :math:`\mathbf{U} = [u_{ij}] \in M_{FCM}`. - The fuzzy clustering of :math:`\mathbf{X}` is represented with :math:`\mathbf{U}` membership matrix. The element - :math:`u_{ij}` is the membership value of j-th object to i-th cluster. In this case, the i-th row of :math:`\mathbf{U}` - matrix is formed with membership values of :math:`n` objects to i-th cluster. :math:`\mathbf{V}` is a prototype vector - of cluster prototypes (centroids): :math:`\mathbf{V = \left\{ v_{1}, v_{2}, ..., v_{c}\right\}}`, - :math:`\mathbf{v_{i}}\in \mathbb{R}^m`. + The fuzzy clustering of :math:`\mathbf{X}` is represented with + :math:`\mathbf{U}` membership matrix. The element :math:`u_{ij}` is the + membership value of j-th object to i-th cluster. In this case, the i-th row + of :math:`\mathbf{U}` matrix is formed with membership values of :math:`n` + objects to i-th cluster. :math:`\mathbf{V}` is a prototype vector of + cluster prototypes (centroids): :math:`\mathbf{V = \left\{ v_{1}, v_{2}, + ..., v_{c}\right\}}`,:math:`\mathbf{v_{i}}\in \mathbb{R}^m`. - :math:`D_{ij}^2` is the squared chosen distance measure which can be any p-norm: - :math:`D_{ij} =\lVert x_{ij} - v_{i} \rVert = \left( \int_I \lvert x_{ij} - v_{i}\rvert^p dx \right)^{ \frac{1}{p}}`, - being :math:`I` the domain where :math:`\mathbf{X}` is defined, :math:`1 \leqslant i \leqslant c`, - :math:`1 \leqslant j\leqslant n_{i}`. Where :math:`n_{i}` represents the number of data points in i-th cluster. + :math:`D_{ij}^2` is the squared chosen distance measure which can be any + p-norm: :math:`D_{ij} =\lVert x_{ij} - v_{i} \rVert = \left( \int_I \lvert + x_{ij} - v_{i}\rvert^p dx \right)^{ \frac{1}{p}}`, being :math:`I` the + domain where :math:`\mathbf{X}` is defined, :math:`1 \leqslant i + \leqslant c`, :math:`1 \leqslant j\leqslant n_{i}`. Where :math:`n_{i}` + represents the number of data points in i-th cluster. - FCM is an iterative process and stops when the number of iterations is reached to maximum, or when - the centroids of the clusters do not change. The steps involved in FCM are: + FCM is an iterative process and stops when the number of iterations is + reached to maximum, or when the centroids of the clusters do not change. + The steps involved in FCM are: - 1. Centroids of :math:`c` clusters are chosen from :math:`\mathbf{X}` randomly or are passed to the - function as a parameter. + 1. Centroids of :math:`c` clusters are chosen from :math:`\mathbf{X}` + randomly or are passed to the function as a parameter. - 2. Membership values of data points to each cluster are calculated with: - :math:`u_{ij} = \left[ \sum_{k=1}^c\left( D_{ij}/D_{kj}\right)^\frac{2}{f-1} \right]^{-1}`. + 2. Membership values of data points to each cluster are calculated + with: :math:`u_{ij} = \left[ \sum_{k=1}^c\left( D_{ij}/D_{kj} + \right)^\frac{2}{f-1} \right]^{-1}`. 3. Cluster centroids are updated by using the following formula: - :math:`\mathbf{v_{i}} =\frac{\sum_{j=1}^{n}u_{ij}^f x_{j}}{\sum_{j=1}^{n} u_{ij}^f}`, - :math:`1 \leqslant i \leqslant c`. + :math:`\mathbf{v_{i}} =\frac{\sum_{j=1}^{n}u_{ij}^f x_{j}}{ + \sum_{j=1}^{n} u_{ij}^f}`, :math:`1 \leqslant i \leqslant c`. - 4. If no cluster centroid changes the run of algorithm is stopped, otherwise return - to step 2. + 4. If no cluster centroid changes the run of algorithm is stopped, + otherwise return to step 2. - This algorithm is applied for each dimension on the image of the FDataGrid object. + This algorithm is applied for each dimension on the image of the FDataGrid + object. - Args: - n_clusters (int, optional): Number of groups into which the samples are + Args: + n_clusters (int, optional): Number of groups into which the samples are classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the different - clusters the algorithm starts with. Its data_marix must be of - the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). + init (FDataGrid, optional): Contains the initial centers of the + different clusters the algorithm starts with. Its data_marix must + be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. - metric (optional): metric that acceps two FDataGrid objects and returns - a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). + metric (optional): metric that acceps two FDataGrid objects and returns + a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). Defaults to *pairwise_distance(lp_distance)*. - n_init (int, optional): Number of time the k-means algorithm will be - run with different centroid seeds. The final results will be the + n_init (int, optional): Number of time the k-means algorithm will be + run with different centroid seeds. The final results will be the best output of n_init consecutive runs in terms of inertia. - max_iter (int, optional): Maximum number of iterations of the + max_iter (int, optional): Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. - tol (float, optional): tolerance used to compare the centroids - calculated with the previous ones in every single run of the + tol (float, optional): tolerance used to compare the centroids + calculated with the previous ones in every single run of the algorithm. - random_state (int, RandomState instance or None, optional): - Determines random number generation for centroid initialization. ç + random_state (int, RandomState instance or None, optional): + Determines random number generation for centroid initialization. Use an int to make the randomness deterministic. Defaults to 0. See :term:`Glossary `. fuzzifier (int, optional): Scalar parameter used to specify the degree of fuzziness in the fuzzy algorithm. Defaults to 2. n_dec (int, optional): designates the number of decimals of the labels returned in the fuzzy algorithm. Defaults to 3. - + Attributes: - labels_ (numpy.ndarray: (nsamples, ndim_image)): 2-dimensional matrix + labels_ (numpy.ndarray: (nsamples, ndim_image)): 2-dimensional matrix in which each row contains the cluster that observation belongs to. - cluster_centers_ (FDataGrid object): data_matrix of shape - (n_clusters, ncol, ndim_image) and contains the centroids for + cluster_centers_ (FDataGrid object): data_matrix of shape + (n_clusters, ncol, ndim_image) and contains the centroids for each cluster. - inertia_ (numpy.ndarray, (fdatagrid.ndim_image)): Sum of squared - distances of samples to their closest cluster center for each + inertia_ (numpy.ndarray, (fdatagrid.ndim_image)): Sum of squared + distances of samples to their closest cluster center for each dimension. - n_iter_ (numpy.ndarray, (fdatagrid.ndim_image)): number of iterations the - algorithm was run for each dimension. + n_iter_ (numpy.ndarray, (fdatagrid.ndim_image)): number of iterations + the algorithm was run for each dimension. Example: @@ -572,7 +605,8 @@ class FuzzyKMeans(BaseKMeans): >>> fuzzy_kmeans.fit(fd, init=init_fd) >>> fuzzy_kmeans FuzzyKMeans(fuzzifier=2, max_iter=100, - metric=.pairwise at 0x7faf3aa06488>, # doctest:+ELLIPSIS + metric=.pairwise at + 0x7faf3aa06488>, # doctest:+ELLIPSIS n_clusters=2, n_dec=3, random_state=0, tol=0.0001) """.replace('+IGNORE_RESULT', '+ELLIPSIS\n<...>') @@ -582,30 +616,33 @@ def __init__(self, n_clusters=2, init=None, """Initialization of the FuzzyKMeans class. Args: - n_clusters (int, optional): Number of groups into which the samples are - classified. Defaults to 2. - init (FDataGrid, optional): Contains the initial centers of the different - clusters the algorithm starts with. Its data_marix must be of - the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). + n_clusters (int, optional): Number of groups into which the samples + are classified. Defaults to 2. + init (FDataGrid, optional): Contains the initial centers of the + different clusters the algorithm starts with. Its data_marix + must be of the shape (n_clusters, fdatagrid.ncol, + fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. - metric (optional): metric that acceps two FDataGrid objects and returns - a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). + metric (optional): metric that acceps two FDataGrid objects and + returns a matrix with shape (fdatagrid1.nsamples, + fdatagrid2.nsamples). Defaults to *pairwise_distance(lp_distance)*. - n_init (int, optional): Number of time the k-means algorithm will be - run with different centroid seeds. The final results will be the - best output of n_init consecutive runs in terms of inertia. + n_init (int, optional): Number of time the k-means algorithm will + be run with different centroid seeds. The final results will be + the best output of n_init consecutive runs in terms of inertia. max_iter (int, optional): Maximum number of iterations of the clustering algorithm for a single run. Defaults to 100. tol (float, optional): tolerance used to compare the centroids calculated with the previous ones in every single run of the algorithm. random_state (int, RandomState instance or None, optional): - Determines random number generation for centroid initialization. - Use an int to make the randomness deterministic. Defaults to 0. + Determines random number generation for centroid + initialization. Use an int to make the randomness + deterministic. Defaults to 0. fuzzifier (int, optional): Scalar parameter used to specify the degree of fuzziness in the fuzzy algorithm. Defaults to 2. - n_dec (int, optional): designates the number of decimals of the labels - returned in the fuzzy algorithm. Defaults to 3. + n_dec (int, optional): designates the number of decimals of the + labels returned in the fuzzy algorithm. Defaults to 3. """ super().__init__(n_clusters=n_clusters, init=init, metric=metric, n_init=n_init, @@ -666,8 +703,8 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): comparison = (fdatagrid.data_matrix[i] == centers).all( axis=tuple(np.arange(fdatagrid.data_matrix.ndim)[1:])) if comparison.sum() >= 1: - U[i, np.where(comparison == True)] = 1 - U[i, np.where(comparison == False)] = 0 + U[i, np.where(comparison is True)] = 1 + U[i, np.where(comparison is False)] = 0 else: for j in range(self.n_clusters): U[i, j] = 1 / np.sum( @@ -680,8 +717,8 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): axis=0) / np.sum(U[:, i]) repetitions += 1 - return np.round(np.power(U, 1 / self.fuzzifier), self.n_dec), centers, \ - distances_to_centers, repetitions + return (np.round(np.power(U, 1 / self.fuzzifier), self.n_dec), centers, + distances_to_centers, repetitions) def fit(self, X, y=None, sample_weight=None): """ Computes Fuzzy K-Means clustering calculating the attributes @@ -716,10 +753,10 @@ def fit(self, X, y=None, sample_weight=None): n_iter = np.empty((self.n_init)) for j in range(self.n_init): - membership_values[j, :, :], centers[j, :, :, :], \ - distances_to_centers[j, :, :], n_iter[j] = \ + (membership_values[j, :, :], centers[j, :, :, :], + distances_to_centers[j, :, :], n_iter[j]) = ( self._fuzzy_kmeans_implementation(fdatagrid=fdatagrid, - random_state=random_state) + random_state=random_state)) distances_to_their_center[j, :] = distances_to_centers[ j, np.arange(fdatagrid.nsamples), np.argmax(membership_values[j, :, :], axis=-1)] @@ -729,7 +766,8 @@ def fit(self, X, y=None, sample_weight=None): self.labels_ = membership_values[index_best_iter] self.cluster_centers_ = FDataGrid(data_matrix=centers[index_best_iter], - sample_points=fdatagrid.sample_points) + sample_points=fdatagrid.sample_points + ) self._distances_to_centers = distances_to_centers[index_best_iter] self.inertia_ = inertia[index_best_iter] self.n_iter_ = n_iter[index_best_iter] From 68586b782ab4de0aeda3bb63384ec866f6019ecd Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 22:42:18 +0200 Subject: [PATCH 100/222] Fixed PEP8 on representation --- skfda/representation/_functional_data.py | 191 +++++++++++----------- skfda/representation/basis.py | 192 ++++++++++++----------- skfda/representation/evaluator.py | 17 +- skfda/representation/extrapolation.py | 19 ++- skfda/representation/grid.py | 4 +- skfda/representation/interpolation.py | 36 +++-- 6 files changed, 242 insertions(+), 217 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index f8a8f3af1..bf949a254 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -125,8 +125,8 @@ def domain_range(self): pass def _reshape_eval_points(self, eval_points, evaluation_aligned): - """Convert and reshape the eval_points to ndarray with the corresponding - shape. + """Convert and reshape the eval_points to ndarray with the + corresponding shape. Args: eval_points (array_like): Evaluation points to be reshaped. @@ -149,12 +149,12 @@ def _reshape_eval_points(self, eval_points, evaluation_aligned): # Creates a copy of the eval points, and convert to np.array eval_points = np.array(eval_points, dtype=float) - if evaluation_aligned: # Samples evaluated at same eval points + if evaluation_aligned: # Samples evaluated at same eval points eval_points = eval_points.reshape((eval_points.shape[0], self.ndim_domain)) - else: # Different eval_points for each sample + else: # Different eval_points for each sample if eval_points.ndim < 2 or eval_points.shape[0] != self.nsamples: @@ -174,8 +174,8 @@ def _extrapolation_index(self, eval_points): Args: eval_points (np.ndarray): Array with shape `n_eval_points` x `ndim_domain` with the evaluation points, or shape ´nsamples´ x - `n_eval_points` x `ndim_domain` with different evaluation points - for each sample. + `n_eval_points` x `ndim_domain` with different evaluation + points for each sample. Returns: @@ -193,7 +193,6 @@ def _extrapolation_index(self, eval_points): return index - def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, aligned_evaluation=True, keepdims=None): """Evaluate the functional object in the cartesian grid. @@ -202,17 +201,17 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, `grid` is True. Evaluates the functional object in the grid generated by the cartesian - product of the axes. The length of the list of axes should be equal than - the domain dimension of the object. + product of the axes. The length of the list of axes should be equal + than the domain dimension of the object. If the list of axes has lengths :math:`n_1, n_2, ..., n_m`, where :math:`m` is equal than the dimension of the domain, the result of the evaluation in the grid will be a matrix with :math:`m+1` dimensions and shape :math:`n_{samples} x n_1 x n_2 x ... x n_m`. - If `aligned_evaluation` is false each sample is evaluated in a different - grid, and the list of axes should contain a list of axes for each - sample. + If `aligned_evaluation` is false each sample is evaluated in a + different grid, and the list of axes should contain a list of axes for + each sample. If the domain dimension is 1, the result of the behaviour of the evaluation will be the same than :meth:`evaluate` without the grid @@ -235,8 +234,8 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, object. Returns: - (numpy.darray): Numpy array with ndim_domain + 1 dimensions with the - result of the evaluation. + (numpy.darray): Numpy array with ndim_domain + 1 dimensions with + the result of the evaluation. Raises: ValueError: If there are a different number of axes than the domain @@ -250,7 +249,8 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, lengths = [len(ax) for ax in axes] if len(axes) != self.ndim_domain: - raise ValueError(f"Length of axes should be {self.ndim_domain}") + raise ValueError(f"Length of axes should be " + f"{self.ndim_domain}") eval_points = _coordinate_list(axes) @@ -261,21 +261,24 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, eval_points = [ax.squeeze(0) for ax in axes] - return self.evaluate(eval_points, derivative=derivative, - extrapolation=extrapolation, keepdims=keepdims, + return self.evaluate(eval_points, + derivative=derivative, + extrapolation=extrapolation, + keepdims=keepdims, aligned_evaluation=False) else: if len(axes) != self.nsamples: - raise ValueError("Should be provided a list of axis per sample") + raise ValueError("Should be provided a list of axis per " + "sample") elif len(axes[0]) != self.ndim_domain: raise ValueError(f"Incorrect length of axes. " f"({self.ndim_domain}) != {len(axes[0])}") lengths = [len(ax) for ax in axes[0]] eval_points = np.empty((self.nsamples, - np.prod(lengths), - self.ndim_domain)) + np.prod(lengths), + self.ndim_domain)) for i in range(self.nsamples): eval_points[i] = _coordinate_list(axes[i]) @@ -295,13 +298,12 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, # Roll the list of result in a list return res.reshape(shape) - def _join_evaluation(self, index_matrix, index_ext, index_ev, res_extrapolation, res_evaluation): """Join the points evaluated. - This method is used internally by :func:`evaluate` to join the result of - the evaluation and the result of the extrapolation. + This method is used internally by :func:`evaluate` to join the result + of the evaluation and the result of the extrapolation. Args: index_matrix (ndarray): Boolean index with the points extrapolated. @@ -318,7 +320,7 @@ def _join_evaluation(self, index_matrix, index_ext, index_ev, """ res = np.empty((self.nsamples, index_matrix.shape[-1], - self.ndim_image)) + self.ndim_image)) # Case aligned evaluation if index_matrix.ndim == 1: @@ -330,7 +332,6 @@ def _join_evaluation(self, index_matrix, index_ext, index_ev, res[:, index_ev] = res_evaluation res[index_matrix] = res_extrapolation[index_matrix[:, index_ext]] - return res @abstractmethod @@ -350,9 +351,9 @@ def _evaluate(self, eval_points, *, derivative=0): Returns: (numpy.darray): Numpy 3d array with shape `(n_samples, - len(eval_points), ndim_image)` with the result of the evaluation. - The entry (i,j,k) will contain the value k-th image dimension of - the i-th sample, at the j-th evaluation point. + len(eval_points), ndim_image)` with the result of the + evaluation. The entry (i,j,k) will contain the value k-th image + dimension of the i-th sample, at the j-th evaluation point. """ pass @@ -368,23 +369,23 @@ def _evaluate_composed(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - `(n_samples, len(eval_points), ndim_domain)` with the evaluation - points for each sample. + `(n_samples, len(eval_points), ndim_domain)` with the + evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3d array with shape `(n_samples, - len(eval_points), ndim_image)` with the result of the evaluation. - The entry (i,j,k) will contain the value k-th image dimension of - the i-th sample, at the j-th evaluation point. + len(eval_points), ndim_image)` with the result of the + evaluation. The entry (i,j,k) will contain the value k-th image + dimension of the i-th sample, at the j-th evaluation point. """ pass - def evaluate(self, eval_points, *, derivative=0, extrapolation=None, grid=False, aligned_evaluation=True, keepdims=None): - """Evaluate the object or its derivatives at a list of values or a grid. + """Evaluate the object or its derivatives at a list of values or + a grid. Args: eval_points (array_like): List of points where the functions are @@ -397,8 +398,8 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, default it is used the mode defined during the instance of the object. grid (bool, optional): Whether to evaluate the results on a grid - spanned by the input arrays, or at points specified by the input - arrays. If true the eval_points should be a list of size + spanned by the input arrays, or at points specified by the + input arrays. If true the eval_points should be a list of size ndim_domain with the corresponding times for each axis. The return matrix has shape nsamples x len(t1) x len(t2) x ... x len(t_ndim_domain) x ndim_image. If the domain dimension is 1 @@ -420,16 +421,18 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, else: # Gets the function to perform extrapolation or None extrapolation = _parse_extrapolation(extrapolation) - extrapolator_evaluator = None + extrapolator_evaluator = None - if grid: # Evaluation of a grid performed in auxiliar function - return self._evaluate_grid(eval_points, derivative=derivative, + if grid: # Evaluation of a grid performed in auxiliar function + return self._evaluate_grid(eval_points, + derivative=derivative, extrapolation=extrapolation, aligned_evaluation=aligned_evaluation, keepdims=keepdims) # Convert to array and check dimensions of eval points - eval_points = self._reshape_eval_points(eval_points, aligned_evaluation) + eval_points = self._reshape_eval_points(eval_points, + aligned_evaluation) # Check if extrapolation should be applied if extrapolation is not None: @@ -439,7 +442,7 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, else: extrapolate = False - if not extrapolate: # Direct evaluation + if not extrapolate: # Direct evaluation if aligned_evaluation: res = self._evaluate(eval_points, derivative=derivative) @@ -476,8 +479,10 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, eval_points_evaluation = eval_points[:, index_ev] # Direct evaluation - res_evaluation = self._evaluate_composed(eval_points_evaluation, - derivative=derivative) + res_evaluation = self._evaluate_composed( + eval_points_evaluation, + derivative=derivative + ) res_extrapolation = extrapolator_evaluator.evaluate_composed( eval_points_extrapolation, @@ -496,11 +501,10 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, return res - def __call__(self, eval_points, *, derivative=0, extrapolation=None, grid=False, aligned_evaluation=True, keepdims=None): - """Evaluate the object or its derivatives at a list of values or a grid. - This method is a wrapper of :meth:`evaluate`. + """Evaluate the object or its derivatives at a list of values or a + grid. This method is a wrapper of :meth:`evaluate`. Args: eval_points (array_like): List of points where the functions are @@ -513,8 +517,8 @@ def __call__(self, eval_points, *, derivative=0, extrapolation=None, default it is used the mode defined during the instance of the object. grid (bool, optional): Whether to evaluate the results on a grid - spanned by the input arrays, or at points specified by the input - arrays. If true the eval_points should be a list of size + spanned by the input arrays, or at points specified by the + input arrays. If true the eval_points should be a list of size ndim_domain with the corresponding times for each axis. The return matrix has shape nsamples x len(t1) x len(t2) x ... x len(t_ndim_domain) x ndim_image. If the domain dimension is 1 @@ -580,12 +584,12 @@ def set_figure_and_axes(self, nrows, ncols): """Set figure and its axes. Args: - nrows(int, optional): designates the number of rows of the figure to - plot the different dimensions of the image. ncols must be also - be customized in the same call. - ncols(int, optional): designates the number of columns of the figure - to plot the different dimensions of the image. nrows must be + nrows(int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. ncols must be also be customized in the same call. + ncols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. nrows + must be also be customized in the same call. Returns: fig (figure object): figure object initialiazed. @@ -625,13 +629,12 @@ def set_figure_and_axes(self, nrows, ncols): # If compatible uses the same figure if (same_projection and geometry == (nrows, ncols) and - len(axes) == self.ndim_image): - return fig, axes + self.ndim_image == len(axes)): + return fig, axes - else: # Create new figure if it is not compatible + else: # Create new figure if it is not compatible fig = plt.figure() - for i in range(self.ndim_image): fig.add_subplot(nrows, ncols, i + 1, projection=projection) @@ -673,7 +676,6 @@ def set_labels(self, fig=None, ax=None, patches=None): if patches is not None: ax[0].legend(handles=patches) - if self.axes_labels is not None: if ax[0].name == '3d': for i in range(self.ndim_image): @@ -686,7 +688,7 @@ def set_labels(self, fig=None, ax=None, patches=None): ax[i].set_ylabel(self.axes_labels[i + 1]) def generic_plotting_checks(self, fig=None, ax=None, nrows=None, - ncols=None): + ncols=None): """Check the arguments passed to both :func:`plot ` and :func:`scatter ` methods of the FDataGrid object. @@ -697,14 +699,14 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, None, the figure is initialized. ax (list of axis objects, optional): axis over where the graphs are plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure to - plot the different dimensions of the image. Only specified if - fig and ax are None. ncols must be also be customized in the - same call. - ncols(int, optional): designates the number of columns of the figure - to plot the different dimensions of the image. Only specified if - fig and ax are None. nrows must be also be customized in the + nrows(int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. ncols must be also be customized in the same call. + ncols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. nrows must be also be + customized in the same call. Returns: fig (figure object): figure object in which the graphs are plotted @@ -724,19 +726,19 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, raise ValueError("Number of axes of the figure must be equal to" "the dimension of the image.") - if ax is not None and len(ax)!= self.ndim_image: - raise ValueError("Number of axes must be equal to the dimension of " - "the image.") + if ax is not None and len(ax) != self.ndim_image: + raise ValueError("Number of axes must be equal to the dimension " + "of the image.") if ((ax is not None or fig is not None) and - (nrows is not None or ncols is not None)): + (nrows is not None or ncols is not None)): raise ValueError("The number of columns and/or number of rows of " - "the figure, in which each dimension of the image " - "is plotted, can only be customized in case fig is" - " None and ax is None.") + "the figure, in which each dimension of the " + "image is plotted, can only be customized in case" + " fig is None and ax is None.") - if ((nrows is not None and ncols is not None) and - nrows*ncols < self.ndim_image): + if ((nrows is not None and ncols is not None) + and ((nrows * ncols) < self.ndim_image)): raise ValueError("The number of columns and the number of rows " "specified is incorrect.") @@ -763,19 +765,19 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, initialized. derivative (int or tuple, optional): Order of derivative to be plotted. In case of surfaces a tuple with the order of - derivation in each direction can be passed. See :func:`evaluate` - to obtain more information. Defaults 0. + derivation in each direction can be passed. See + :func:`evaluate` to obtain more information. Defaults 0. fig (figure object, optional): figure over with the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. ax (list of axis objects, optional): axis over where the graphs are plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure to - plot the different dimensions of the image. Only specified if - fig and ax are None. - ncols(int, optional): designates the number of columns of the figure - to plot the different dimensions of the image. Only specified if - fig and ax are None. + nrows(int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + ncols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. npoints (int or tuple, optional): Number of points to evaluate in the plot. In case of surfaces a tuple of length 2 can be pased with the number of points to plot in each axis, otherwise the @@ -794,16 +796,16 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, the samples with the same label are plotted in the same color. If None, the default value, each sample is plotted in the color assigned by matplotlib.pyplot.rcParams['axes.prop_cycle']. - label_colors (list of colors): colors in which groups are represented, - there must be one for each group. If None, each group is shown - with distict colors in the "Greys" colormap. + label_colors (list of colors): colors in which groups are + represented, there must be one for each group. If None, each + group is shown with distict colors in the "Greys" colormap. label_names (list of str): name of each of the groups which appear in a legend, there must be one for each one. Defaults to None and the legend is not shown. - **kwargs: if ndim_domain is 1, keyword arguments to be passed to the - matplotlib.pyplot.plot function; if ndim_domain is 2, keyword - arguments to be passed to the matplotlib.pyplot.plot_surface - function. + **kwargs: if ndim_domain is 1, keyword arguments to be passed to + the matplotlib.pyplot.plot function; if ndim_domain is 2, + keyword arguments to be passed to the + matplotlib.pyplot.plot_surface function. Returns: fig (figure object): figure object in which the graphs are plotted. @@ -855,7 +857,6 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, colormap = plt.cm.get_cmap('Greys') sample_colors = colormap(sample_labels / (nlabels - 1)) - if label_names is not None: if len(label_names) != nlabels: raise ValueError("There must be a name in label_names " @@ -899,7 +900,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, for j in range(self.nsamples): if sample_labels is None and next_color: sample_colors[j] = ax[i]._get_lines.get_next_color() - ax[i].plot(eval_points, mat[j,..., i].T, + ax[i].plot(eval_points, mat[j, ..., i].T, c=sample_colors[j], **kwargs) else: @@ -918,7 +919,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, y = np.linspace(*domain_range[1], npoints[1]) # Evaluation of the functional object - Z = self((x,y), derivative=derivative, grid=True, keepdims=True) + Z = self((x, y), derivative=derivative, grid=True, keepdims=True) X, Y = np.meshgrid(x, y, indexing='ij') @@ -926,7 +927,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, for j in range(self.nsamples): if sample_labels is None and next_color: sample_colors[j] = ax[i]._get_lines.get_next_color() - ax[i].plot_surface(X, Y, Z[j,...,i], + ax[i].plot_surface(X, Y, Z[j, ..., i], color=sample_colors[j], **kwargs) self.set_labels(fig, ax, patches) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index d8c62a43b..5679974ee 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -154,7 +154,8 @@ def plot(self, chart=None, *, derivative=0, **kwargs): plotted. derivative (int or tuple, optional): Order of derivative to be plotted. Defaults 0. - **kwargs: keyword arguments to be passed to the fdata.plot function. + **kwargs: keyword arguments to be passed to the + fdata.plot function. Returns: fig (figure object): figure object in which the graphs are plotted. @@ -225,9 +226,9 @@ def _numerical_penalty(self, coefficients): for j in range(i + 1, self.nbasis): penalty_matrix[i, j] = scipy.integrate.quad( lambda x: (self._evaluate_single_basis_coefficients( - coefficients, i, x, cache) - * self._evaluate_single_basis_coefficients( - coefficients, j, x, cache)), + coefficients, i, x, cache) * + self._evaluate_single_basis_coefficients( + coefficients, j, x, cache)), domain_range[0], domain_range[1] )[0] penalty_matrix[j, i] = penalty_matrix[i, j] @@ -292,9 +293,9 @@ def rescale(self, domain_range=None): corresponding values rescaled to the new bounds. Args: - domain_range (tuple, optional): Definition of the interval where - the basis defines a space. Defaults uses the same as the - original basis. + domain_range (tuple, optional): Definition of the interval + where the basis defines a space. Defaults uses the same as + the original basis. """ if domain_range is None: @@ -372,8 +373,8 @@ def gram_matrix(self): .. math:: G_{ij} = \langle\phi_i, \phi_j\rangle - where :math:`\phi_i` is the ith element of the basis. This is a symmetric matrix and - positive-semidefinite. + where :math:`\phi_i` is the ith element of the basis. This is a + symmetric matrix and positive-semidefinite. Returns: numpy.array: Gram Matrix of the basis. @@ -493,7 +494,8 @@ def _compute_matrix(self, eval_points, derivative=0): eval_points. """ - return np.ones((1, len(eval_points))) if derivative == 0 else np.zeros((1, len(eval_points))) + return np.ones((1, len(eval_points))) if derivative == 0\ + else np.zeros((1, len(eval_points))) def penalty(self, derivative_degree=None, coefficients=None): r"""Return a penalty matrix given a differential operator. @@ -538,7 +540,8 @@ def penalty(self, derivative_degree=None, coefficients=None): if derivative_degree is None: return self._numerical_penalty(coefficients) - return (np.full((1, 1), (self.domain_range[0][1] - self.domain_range[0][0])) + return (np.full((1, 1), + (self.domain_range[0][1] - self.domain_range[0][0])) if derivative_degree == 0 else np.zeros((1, 1))) def basis_of_product(self, other): @@ -873,8 +876,8 @@ def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): if (nbasis - order + 2) < 2: raise ValueError(f"The number of basis ({nbasis}) minus the order " - f"of the bspline ({order}) should be greater than " - f"3.") + f"of the bspline ({order}) should be greater " + f"than 3.") if domain_range[0] != knots[0] or domain_range[1] != knots[-1]: raise ValueError("The ends of the knots must be the same as " @@ -929,8 +932,8 @@ def _compute_matrix(self, eval_points, derivative=0): """ # Places m knots at the boundaries - knots = np.array([self.knots[0]] * (self.order - 1) + self.knots - + [self.knots[-1]] * (self.order - 1)) + knots = np.array([self.knots[0]] * (self.order - 1) + self.knots + + [self.knots[-1]] * (self.order - 1)) # c is used the select which spline the function splev below computes c = np.zeros(len(knots)) @@ -997,8 +1000,8 @@ def penalty(self, derivative_degree=None, coefficients=None): if derivative_degree is not None: if derivative_degree >= self.order: raise ValueError(f"Penalty matrix cannot be evaluated for " - f"derivative of order {derivative_degree} for " - f"B-splines of order {self.order}") + f"derivative of order {derivative_degree} for" + f" B-splines of order {self.order}") if derivative_degree == self.order - 1: # The derivative of the bsplines are constant in the intervals # defined between knots @@ -1038,19 +1041,19 @@ def penalty(self, derivative_degree=None, coefficients=None): # meaning that the column i corresponds to the ith knot. # Let the ith not be a # Then f(x) = pp(x - a) - pp = (PPoly.from_spline((knots, c, self.order - 1)) - .c[:, no_0_intervals]) + pp = (PPoly.from_spline((knots, c, self.order - 1)).c[:, + no_0_intervals]) # We need the actual coefficients of f, not pp. So we # just recursively calculate the new coefficients coeffs = pp.copy() for j in range(self.order - 1): coeffs[j + 1:] += ( (binom(self.order - j - 1, - range(1, self.order - j)) - * np.vstack([(-a) ** np.array( - range(1, self.order - j)) for a in - self.knots[:-1]]) - ).T * pp[j]) + range(1, self.order - j)) * + np.vstack([(-a) ** + np.array(range(1, self.order - j)) + for a in self.knots[:-1]])).T * + pp[j]) ppoly_lst.append(coeffs) c[i] = 0 @@ -1060,7 +1063,7 @@ def penalty(self, derivative_degree=None, coefficients=None): for interval in range(len(no_0_intervals)): for i in range(self.nbasis): poly_i = np.trim_zeros(ppoly_lst[i][:, - interval], 'f') + interval], 'f') if len(poly_i) <= derivative_degree: # if the order of the polynomial is lesser or # equal to the derivative the result of the @@ -1075,7 +1078,7 @@ def penalty(self, derivative_degree=None, coefficients=None): for j in range(i + 1, self.nbasis): poly_j = np.trim_zeros(ppoly_lst[j][:, - interval], 'f') + interval], 'f') if len(poly_j) <= derivative_degree: # if the order of the polynomial is lesser # or equal to the derivative the result of @@ -1096,8 +1099,8 @@ def penalty(self, derivative_degree=None, coefficients=None): # of the bspline minus 1 if len(coefficients) >= self.order: raise ValueError(f"Penalty matrix cannot be evaluated for " - f"derivative of order {len(coefficients) - 1} " - f"for B-splines of order {self.order}") + f"derivative of order {len(coefficients) - 1}" + f" for B-splines of order {self.order}") # compute using the inner product return self._numerical_penalty(coefficients) @@ -1108,9 +1111,9 @@ def rescale(self, domain_range=None): The knots of the BSpline will be rescaled in the new interval. Args: - domain_range (tuple, optional): Definition of the interval where - the basis defines a space. Defaults uses the same as the - original basis. + domain_range (tuple, optional): Definition of the interval + where the basis defines a space. Defaults uses the same as + the original basis. """ knots = np.array(self.knots, dtype=np.dtype('float')) @@ -1171,7 +1174,9 @@ def basis_of_product(self, other): norder2 = other.nbasis - len(other.inknots) norder = min(norder1 + norder2 - 1, 20) - allbreaks = [self.domain_range[0][0]] + np.ndarray.tolist(allknots) + [self.domain_range[0][1]] + allbreaks = ([self.domain_range[0][0]] + + np.ndarray.tolist(allknots) + + [self.domain_range[0][1]]) nbasis = len(allbreaks) + norder - 2 return BSpline(self.domain_range, nbasis, norder, allbreaks) else: @@ -1337,9 +1342,9 @@ def _compute_matrix(self, eval_points, derivative=0): if nbasis > 1: # (2*pi*n / period) ^ n_derivative factor = np.outer( - (-1) ** (derivative // 2) - * (np.array(range(1, nbasis // 2 + 1)) * omega) - ** derivative, + (-1) ** (derivative // 2) * + (np.array(range(1, nbasis // 2 + 1)) * omega) ** + derivative, np.ones(len(eval_points))) # 2*pi*n*x / period args = np.outer(range(1, nbasis // 2 + 1), omega_t) @@ -1374,11 +1379,12 @@ def _ndegenerated(self, penalty_degree): def _derivative(self, coefs, order=1): omega = 2 * np.pi / self.period - deriv_factor = (np.arange(1, (self.nbasis+1)/2) * omega) ** order + deriv_factor = (np.arange(1, (self.nbasis + 1) / 2) * omega) ** order deriv_coefs = np.zeros(coefs.shape) - cos_sign, sin_sign = (-1)**int((order+1)/2), (-1)**int(order/2) + cos_sign, sin_sign = ((-1) ** int((order + 1) / 2), + (-1) ** int(order / 2)) if order % 2 == 0: deriv_coefs[:, 1::2] = sin_sign * coefs[:, 1::2] * deriv_factor @@ -1468,9 +1474,9 @@ def rescale(self, domain_range=None, *, rescale_period=False): corresponding values rescaled to the new bounds. Args: - domain_range (tuple, optional): Definition of the interval where - the basis defines a space. Defaults uses the same as the - original basis. + domain_range (tuple, optional): Definition of the interval + where the basis defines a space. Defaults uses the same as + the original basis. rescale_period (bool, optional): If true the period will be rescaled using the ratio between the lengths of the new and old interval. Defaults to False. @@ -1478,15 +1484,15 @@ def rescale(self, domain_range=None, *, rescale_period=False): rescale_basis = super().rescale(domain_range) - if rescale_period == False: + if rescale_period is False: rescale_basis.period = self.period else: domain_rescaled = rescale_basis.domain_range[0] domain = self.domain_range[0] - rescale_basis.period = self.period * \ - (domain_rescaled[1] - domain_rescaled[0] - ) / (domain[1] - domain[0]) + rescale_basis.period = (self.period * + (domain_rescaled[1] - domain_rescaled[0]) / + (domain[1] - domain[0])) return rescale_basis @@ -1556,14 +1562,13 @@ def __init__(self, basis, coefficients, *, dataset_label=None, self.basis = basis self.coefficients = coefficients - #self.dataset_label = dataset_label - #self.axes_labels = axes_labels - #self.extrapolation = extrapolation - #self.keepdims = keepdims + # self.dataset_label = dataset_label + # self.axes_labels = axes_labels + # self.extrapolation = extrapolation + # self.keepdims = keepdims super().__init__(extrapolation, dataset_label, axes_labels, keepdims) - @classmethod def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, smoothness_parameter=0, penalty_degree=None, @@ -1613,9 +1618,9 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, basis: (Basis): Basis used. weight_matrix (array_like, optional): Matrix to weight the observations. Defaults to the identity matrix. - smoothness_parameter (int or float, optional): Smoothness parameter. - Trying with several factors in a logarythm scale is suggested. - If 0 no smoothing is performed. Defaults to 0. + smoothness_parameter (int or float, optional): Smoothness + parameter. Trying with several factors in a logarythm scale is + suggested. If 0 no smoothing is performed. Defaults to 0. penalty_degree (int): Integer indicating the order of the derivative used in the computing of the penalty matrix. For instance 2 means that the differential operator is @@ -1653,16 +1658,13 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, References: .. [RS05-5-2-5] Ramsay, J., Silverman, B. W. (2005). How spline - smooths are computed. In *Functional Data Analysis* (pp. 86-87). - Springer. + smooths are computed. In *Functional Data Analysis* + (pp. 86-87). Springer. .. [RS05-5-2-7] Ramsay, J., Silverman, B. W. (2005). HSpline smoothing as an augmented least squares problem. In *Functional Data Analysis* (pp. 86-87). Springer. - - - """ # TODO add an option to return fit summaries: yhat, sse, gcv... if penalty_degree is None and penalty_coefficients is None: @@ -1720,8 +1722,8 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, if smoothness_parameter > 0: # In this case instead of resolving the original equation - # we expand the system to include the penalty matrix so that - # the rounding error is reduced + # we expand the system to include the penalty matrix so + # that the rounding error is reduced if not penalty_matrix: penalty_matrix = basis.penalty(penalty_degree, penalty_coefficients) @@ -1746,9 +1748,9 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, axis=0) # Augment data matrix by n - ndegenerated zeros data_matrix = np.pad(data_matrix, - ((0, len(v) - ndegenerated), - (0, 0)), - mode='constant') + ((0, len(v) - ndegenerated), + (0, 0)), + mode='constant') # Resolves the equation # B.T @ B @ C = B.T @ D @@ -1839,8 +1841,8 @@ def _evaluate_composed(self, eval_points, *, derivative=0): r"""Evaluate the object or its derivatives at a list of values with a different time for each sample. - Returns a numpy array with the component (i,j) equal to :math:`f_i(t_j + - \delta_i)`. + Returns a numpy array with the component (i,j) equal to :math:`f_i(t_j + + \delta_i)`. This method has to evaluate the basis values once per sample instead of reuse the same evaluation for all the samples @@ -1879,8 +1881,8 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, Args: shifts (array_like or numeric): List with the the shift - corresponding for each sample or numeric with the shift to apply - to all samples. + corresponding for each sample or numeric with the shift to + apply to all samples. restrict_domain (bool, optional): If True restricts the domain to avoid evaluate points outside the domain using extrapolation. Defaults uses extrapolation. @@ -1922,8 +1924,8 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, _basis, **kwargs) elif len(shifts) != self.nsamples: - raise ValueError(f"shifts vector ({len(shifts)}) must have the same" - f" length than the number of samples " + raise ValueError(f"shifts vector ({len(shifts)}) must have the " + f"same length than the number of samples " f"({self.nsamples})") if restrict_domain: @@ -1932,12 +1934,12 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, domain = (a, b) eval_points = eval_points[ np.logical_and(eval_points >= a, - eval_points <= b)] + eval_points <= b)] else: domain = domain_range points_shifted = np.outer(np.ones(self.nsamples), - eval_points) + eval_points) points_shifted += np.atleast_2d(shifts).T @@ -1963,7 +1965,7 @@ def derivative(self, order=1): if order < 0: raise ValueError("order only takes non-negative integer values.") - if order is 0: + if order == 0: return self.copy() basis, coefficients = self.basis._derivative(self.coefficients, order) @@ -2173,11 +2175,15 @@ def times(self, other): evalarg = np.linspace(left, right, neval) first = self.copy(coefficients=(np.repeat(self.coefficients, - other.nsamples, axis=0) if self.nsamples == 1 and - other.nsamples > 1 else self.coefficients.copy())) + other.nsamples, axis=0) + if (self.nsamples == 1 and + other.nsamples > 1) + else self.coefficients.copy())) second = other.copy(coefficients=(np.repeat(other.coefficients, - self.nsamples, axis=0) if other.nsamples == 1 and - self.nsamples > 1 else other.coefficients.copy())) + self.nsamples, axis=0) + if (other.nsamples == 1 and + self.nsamples > 1) + else other.coefficients.copy())) fdarray = first.evaluate(evalarg) * second.evaluate(evalarg) @@ -2187,7 +2193,7 @@ def times(self, other): other = [other for _ in range(self.nsamples)] coefs = np.transpose(np.atleast_2d(other)) - return self.copy(coefficients=self.coefficients*coefs) + return self.copy(coefficients=self.coefficients * coefs) def inner_product(self, other, lfd_self=None, lfd_other=None, weights=None): @@ -2213,9 +2219,11 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, other (FDataBasis, Basis): FDataBasis object containing the second object to make the inner product - lfd_self (Lfd): LinearDifferentialOperator object for the first function evaluation + lfd_self (Lfd): LinearDifferentialOperator object for the first + function evaluation - lfd_other (Lfd): LinearDifferentialOperator object for the second function evaluation + lfd_other (Lfd): LinearDifferentialOperator object for the second + function evaluation weights(FDataBasis): a FDataBasis object with only one sample that defines the weight to calculate the inner product @@ -2241,7 +2249,9 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, other = other.times(weights) if self.nsamples * other.nsamples > self.nbasis * other.nbasis: - return self.coefficients @ self.basis._inner_matrix(other.basis) @ other.coefficients.T + return (self.coefficients @ + self.basis._inner_matrix(other.basis) @ + other.coefficients.T) else: return self._inner_product_integrate(other, lfd_self, lfd_other) @@ -2302,8 +2312,8 @@ def __str__(self): def __eq__(self, other): """Equality of FDataBasis""" # TODO check all other params - return (self.basis == other.basis - and np.all(self.coefficients == other.coefficients)) + return (self.basis == other.basis and + np.all(self.coefficients == other.coefficients)) def concatenate(self, other): """Join samples from a similar FDataBasis object. @@ -2315,16 +2325,16 @@ def concatenate(self, other): other (:class:`FDataBasis`): another FDataBasis object. Returns: - :class:`FDataBasis`: FDataBasis object with the samples from the two - original objects. + :class:`FDataBasis`: FDataBasis object with the samples from the + two original objects. """ if other.basis != self.basis: raise ValueError("The objects should have the same basis.") return self.copy(coefficients=np.concatenate((self.coefficients, - other.coefficients), - axis=0)) + other.coefficients), + axis=0)) def compose(self, fd, *, eval_points=None, **kwargs): """Composition of functions. @@ -2345,7 +2355,7 @@ def compose(self, fd, *, eval_points=None, **kwargs): basis = self.basis.rescale(fd.domain_range[0]) composition = grid.to_basis(basis, **kwargs) else: - # Cant be convertered to basis due to the dimensions + #  Cant be convertered to basis due to the dimensions composition = grid return composition @@ -2365,10 +2375,11 @@ def __add__(self, other): raise NotImplementedError else: basis, coefs = self.basis._add_same_basis(self.coefficients, - other.coefficients) + other.coefficients) else: try: - basis, coefs = self.basis._add_constant(self.coefficients, other) + basis, coefs = self.basis._add_constant(self.coefficients, + other) except TypeError: return NotImplemented @@ -2386,10 +2397,11 @@ def __sub__(self, other): raise NotImplementedError else: basis, coefs = self.basis._sub_same_basis(self.coefficients, - other.coefficients) + other.coefficients) else: try: - basis, coefs = self.basis._sub_constant(self.coefficients, other) + basis, coefs = self.basis._sub_constant(self.coefficients, + other) except TypeError: return NotImplemented diff --git a/skfda/representation/evaluator.py b/skfda/representation/evaluator.py index c5abcdd5f..8b76418ae 100644 --- a/skfda/representation/evaluator.py +++ b/skfda/representation/evaluator.py @@ -46,7 +46,8 @@ class Evaluator(ABC): The evaluator is called internally by :func:`evaluate`. - Should implement the methods :func:`evaluate` and :func:`evaluate_composed`. + Should implement the methods :func:`evaluate` and + :func:`evaluate_composed`. """ @@ -54,8 +55,8 @@ class Evaluator(ABC): def evaluate(self, eval_points, *, derivative=0): """Evaluation method. - Evaluates the samples at the same evaluation points. The evaluation call - will receive a 2-d array with the evaluation points. + Evaluates the samples at the same evaluation points. The evaluation + call will receive a 2-d array with the evaluation points. This method is called internally by :meth:`evaluate` when the argument `aligned_evaluation` is True. @@ -81,7 +82,8 @@ def evaluate_composed(self, eval_points, *, derivative=0): """Evaluation method. Evaluates the samples at different evaluation points. The evaluation - call will receive a 3-d array with the evaluation points for each sample. + call will receive a 3-d array with the evaluation points for each + sample. This method is called internally by :meth:`evaluate` when the argument `aligned_evaluation` is False. @@ -123,8 +125,8 @@ def __init__(self, fdata, evaluate_func, evaluate_composed_func=None): def evaluate(self, eval_points, *, derivative=0): """Evaluation method. - Evaluates the samples at the same evaluation points. The evaluation call - will receive a 2-d array with the evaluation points. + Evaluates the samples at the same evaluation points. The evaluation + call will receive a 2-d array with the evaluation points. This method is called internally by :meth:`evaluate` when the argument `aligned_evaluation` is True. @@ -150,7 +152,8 @@ def evaluate_composed(self, eval_points, *, derivative=0): """Evaluation method. Evaluates the samples at different evaluation points. The evaluation - call will receive a 3-d array with the evaluation points for each sample. + call will receive a 3-d array with the evaluation points for each + sample. This method is called internally by :meth:`evaluate` when the argument `aligned_evaluation` is False. diff --git a/skfda/representation/extrapolation.py b/skfda/representation/extrapolation.py index bf636aae4..12f82ac2c 100644 --- a/skfda/representation/extrapolation.py +++ b/skfda/representation/extrapolation.py @@ -4,8 +4,6 @@ """ -from abc import ABC, abstractmethod - from .evaluator import EvaluatorConstructor, Evaluator, GenericEvaluator import numpy as np @@ -17,7 +15,8 @@ class PeriodicExtrapolation(EvaluatorConstructor): Examples: >>> from skfda.datasets import make_sinusoidal_process - >>> from skfda.representation.extrapolation import PeriodicExtrapolation + >>> from skfda.representation.extrapolation import ( + ... PeriodicExtrapolation) >>> fd = make_sinusoidal_process(n_samples=2, random_state=0) We can set the default type of extrapolation @@ -82,7 +81,8 @@ class BoundaryExtrapolation(EvaluatorConstructor): Examples: >>> from skfda.datasets import make_sinusoidal_process - >>> from skfda.representation.extrapolation import BoundaryExtrapolation + >>> from skfda.representation.extrapolation import ( + ... BoundaryExtrapolation) >>> fd = make_sinusoidal_process(n_samples=2, random_state=0) We can set the default type of extrapolation @@ -149,7 +149,8 @@ class ExceptionExtrapolation(EvaluatorConstructor): Examples: >>> from skfda.datasets import make_sinusoidal_process - >>> from skfda.representation.extrapolation import ExceptionExtrapolation + >>> from skfda.representation.extrapolation import ( + ... ExceptionExtrapolation) >>> fd = make_sinusoidal_process(n_samples=2, random_state=0) We can set the default type of extrapolation @@ -246,8 +247,9 @@ def fill_value(self): def __eq__(self, other): """Equality operator bethween evaluator constructors""" - return super().__eq__(other) and (self.fill_value == other.fill_value - or self.fill_value is other.fill_value) + return (super().__eq__(other) and + (self.fill_value == other.fill_value + or self.fill_value is other.fill_value)) def evaluator(self, fdata): @@ -288,7 +290,8 @@ def evaluate_composed(self, eval_points, *, derivative=0): """Evaluation method. Evaluates the samples at different evaluation points. The evaluation - call will receive a 3-d array with the evaluation points for each sample. + call will receive a 3-d array with the evaluation points for + each sample. This method is called internally by :meth:`evaluate` when the argument `aligned_evaluation` is False. diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index e28b29abf..96e92bbac 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -151,7 +151,7 @@ def __init__(self, data_matrix, sample_points=None, for i in range(self.ndim_domain)]) if domain_range is None: - self._domain_range = self.sample_range + self._domain_range = self.sample_range # Default value for domain_range is a list of tuples with # the first and last element of each list ofthe sample_points. else: @@ -933,7 +933,7 @@ def compose(self, fd, *, eval_points=None): for i in range(self.nsamples): eval_points_transformation[i] = np.array( list(map(np.ravel, grid_transformation[i].T)) - ).T + ).T data_flatten = self(eval_points_transformation, aligned_evaluation=False) diff --git a/skfda/representation/interpolation.py b/skfda/representation/interpolation.py index 261b31c20..435973bf3 100644 --- a/skfda/representation/interpolation.py +++ b/skfda/representation/interpolation.py @@ -2,7 +2,6 @@ Module to interpolate functional data objects. """ -from abc import ABC, abstractmethod import numpy as np @@ -10,7 +9,6 @@ from scipy.interpolate import (PchipInterpolator, UnivariateSpline, RectBivariateSpline, RegularGridInterpolator) -from .._utils import _list_of_arrays from .evaluator import Evaluator, EvaluatorConstructor @@ -57,8 +55,9 @@ def __init__(self, interpolation_order=1, smoothness_parameter=0., surfaces. If 0 the residuals of the interpolation will be 0. Defaults 0. monotone (boolean, optional): Performs monotone interpolation in - curves using a PCHIP interpolator. Only valid for curves (domain - dimension equal to 1) and interpolation order equal to 1 or 3. + curves using a PCHIP interpolator. Only valid for curves + (domain dimension equal to 1) and interpolation order equal + to 1 or 3. Defaults false. """ @@ -157,8 +156,9 @@ def __init__(self, fdatagrid, k=1, s=0., monotone=False): surfaces. If 0 the residuals of the interpolation will be 0. Defaults 0. monotone (boolean, optional): Performs monotone interpolation in - curves using a PCHIP interpolator. Only valid for curves (domain - dimension equal to 1) and interpolation order equal to 1 or 3. + curves using a PCHIP interpolator. Only valid for curves + (domain dimension equal to 1) and interpolation order equal to + 1 or 3. Defaults false. """ @@ -196,11 +196,13 @@ def __init__(self, fdatagrid, k=1, s=0., monotone=False): # be deleted self._fdatagrid = None - def _construct_spline_1_m(self, sample_points, data_matrix, k, s, monotone): + def _construct_spline_1_m(self, sample_points, data_matrix, + k, s, monotone): r"""Construct the matrix of interpolators for curves. Constructs the matrix of interpolators for objects with domain - dimension = 1. Calling internally during the creationg of the evaluator. + dimension = 1. Calling internally during the creationg of the + evaluator. Uses internally the scipy interpolator UnivariateSpline or PchipInterpolator. @@ -269,7 +271,8 @@ def _construct_spline_2_m(self, sample_points, data_matrix, k, s): r"""Construct the matrix of interpolators for surfaces. Constructs the matrix of interpolators for objects with domain - dimension = 2. Calling internally during the creationg of the evaluator. + dimension = 2. Calling internally during the creationg of the + evaluator. Uses internally the scipy interpolator RectBivariateSpline. @@ -306,7 +309,7 @@ def _spline_evaluator_2_m(spl, t, der): def _process_derivative_2_m(derivative): if np.isscalar(derivative): - derivative = 2*[derivative] + derivative = 2 * [derivative] elif len(derivative) != 2: raise ValueError("derivative should be a numeric value " "or a tuple of length 2 with (dx,dy).") @@ -317,7 +320,7 @@ def _process_derivative_2_m(derivative): self._spline_evaluator = _spline_evaluator_2_m self._process_derivative = _process_derivative_2_m - # Matrix of splines + # Matrix of splines spline = np.empty((self._nsamples, self._ndim_image), dtype=object) for i in range(self._nsamples): @@ -333,7 +336,8 @@ def _construct_spline_n_m(self, sample_points, data_matrix, k): r"""Construct the matrix of interpolators. Constructs the matrix of interpolators for objects with domain - dimension > 2. Calling internally during the creationg of the evaluator. + dimension > 2. Calling internally during the creationg of the + evaluator. Only linear and nearest interpolators are available for objects with domain dimension >= 3. Uses internally the scipy interpolator @@ -392,7 +396,8 @@ def evaluate(self, eval_points, *, derivative=0): r"""Evaluation method. Evaluates the samples at different evaluation points. The evaluation - call will receive a 3-d array with the evaluation points for each sample. + call will receive a 3-d array with the evaluation points for + each sample. This method is called internally by :meth:`evaluate` when the argument `aligned_evaluation` is False. @@ -416,7 +421,7 @@ def evaluate(self, eval_points, *, derivative=0): """ derivative = self._process_derivative(derivative) - # Constructs the evaluator for t_eval + # Constructs the evaluator for t_eval if self._ndim_image == 1: def evaluator(spl): """Evaluator of object with image dimension equal to 1.""" @@ -439,7 +444,8 @@ def evaluate_composed(self, eval_points, *, derivative=0): """Evaluation method. Evaluates the samples at different evaluation points. The evaluation - call will receive a 3-d array with the evaluation points for each sample. + call will receive a 3-d array with the evaluation points for + each sample. This method is called internally by :meth:`evaluate` when the argument `aligned_evaluation` is False. From 9bc76f8f4b4d45c8b610b34a37c4f88dd1959a4e Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 22:58:24 +0200 Subject: [PATCH 101/222] More PEP8 fixes --- skfda/datasets/_samples_generators.py | 14 +++++----- skfda/exploratory/visualization/boxplot.py | 18 ++++++------ .../visualization/clustering_plots.py | 4 +-- skfda/misc/_math.py | 2 +- skfda/misc/kernels.py | 4 +-- skfda/misc/metrics.py | 2 +- skfda/ml/clustering/base_kmeans.py | 8 +++--- skfda/preprocessing/registration/_elastic.py | 13 +++++---- .../registration/_registration_utils.py | 2 +- .../registration/_shift_registration.py | 2 +- skfda/representation/basis.py | 28 +++++++++---------- 11 files changed, 49 insertions(+), 48 deletions(-) diff --git a/skfda/datasets/_samples_generators.py b/skfda/datasets/_samples_generators.py index a74788026..8af1417a8 100644 --- a/skfda/datasets/_samples_generators.py +++ b/skfda/datasets/_samples_generators.py @@ -100,12 +100,12 @@ def make_sinusoidal_process(n_samples: int = 15, n_features: int = 100, *, alpha = np.diag(random_state.normal(amplitude_mean, amplitude_std, n_samples)) - phi = np.outer(random_state.normal(phase_mean, phase_std, n_samples), + phi = np.outer(random_state.normal(phase_mean, phase_std, n_samples), np.ones(n_features)) error = random_state.normal(0, error_std, (n_samples, n_features)) - y = alpha @ np.sin((2*np.pi/period)*t + phi) + error + y = alpha @ np.sin((2 * np.pi / period) * t + phi) + error return FDataGrid(sample_points=t, data_matrix=y) @@ -255,7 +255,7 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, cov) # Constant to make modes value aprox. 1 - data_matrix *= (2*np.pi*mode_std)**(ndim_domain/2) + data_matrix *= (2 * np.pi * mode_std) ** (ndim_domain / 2) data_matrix += random_state.normal(0, noise, size=data_matrix.shape) @@ -326,11 +326,11 @@ def make_random_warping(n_samples: int = 15, n_features: int = 100, *, v = np.outer(np.ones(n_features), random_state.normal(scale=sqrt_sigma, size=n_samples)) - for j in range(2, 2+n_random): + for j in range(2, 2 + n_random): alpha = random_state.normal(scale=sqrt_sigma, size=(2, n_samples)) alpha *= sqrt2 - v += alpha[0] * np.cos(j*omega*time) - v += alpha[1] * np.sin(j*omega*time) + v += alpha[0] * np.cos(j * omega * time) + v += alpha[1] * np.sin(j * omega * time) v -= v.mean(axis=0) @@ -344,7 +344,7 @@ def make_random_warping(n_samples: int = 15, n_features: int = 100, *, np.square(v, out=v) # Creation of FDataGrid in the corresponding domain - data_matrix = scipy.integrate.cumtrapz(v, dx=1./n_features, initial=0, + data_matrix = scipy.integrate.cumtrapz(v, dx=1. / n_features, initial=0, axis=0) warping = FDataGrid(data_matrix.T, sample_points=time[:, 0]) warping = normalize_warping(warping, domain_range=(start, stop)) diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/boxplot.py index 4435e2eac..c98972917 100644 --- a/skfda/exploratory/visualization/boxplot.py +++ b/skfda/exploratory/visualization/boxplot.py @@ -228,7 +228,7 @@ def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], for i in range(len(prob)): indices_samples = indices_descencing_depth[:, m][ - :math.ceil(fdatagrid.nsamples * prob[i])] + :math.ceil(fdatagrid.nsamples * prob[i])] samples_used = fdatagrid.data_matrix[indices_samples, :, m] max_samples_used = np.amax(samples_used, axis=0) min_samples_used = np.amin(samples_used, axis=0) @@ -253,11 +253,11 @@ def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], # outliers for j in list(range(fdatagrid.nsamples)): outliers_above = ( - outlying_max_envelope < fdatagrid.data_matrix[ - j, :, m]) + outlying_max_envelope < fdatagrid.data_matrix[ + j, :, m]) outliers_below = ( - outlying_min_envelope > fdatagrid.data_matrix[ - j, :, m]) + outlying_min_envelope > fdatagrid.data_matrix[ + j, :, m]) if (outliers_above.sum() > 0 or outliers_below.sum() > 0): self._outliers[m, j] = 1 @@ -267,7 +267,7 @@ def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], # mean sample self._median[m] = fdatagrid.data_matrix[ - indices_descencing_depth[0, m], :, m].T + indices_descencing_depth[0, m], :, m].T self._fdatagrid = fdatagrid self._prob = prob @@ -539,14 +539,14 @@ def __init__(self, fdatagrid, method=modified_band_depth, factor=1.5): for m in range(fdatagrid.ndim_image): indices_samples = indices_descencing_depth[:, m][ - :math.ceil(fdatagrid.nsamples * 0.5)] + :math.ceil(fdatagrid.nsamples * 0.5)] samples_used = fdatagrid.data_matrix[indices_samples, :, :, m] max_samples_used = np.amax(samples_used, axis=0) min_samples_used = np.amin(samples_used, axis=0) # mean sample self._median[m] = fdatagrid.data_matrix[ - indices_descencing_depth[0, m], :, :, m] + indices_descencing_depth[0, m], :, :, m] # central envelope self._central_envelope[m] = np.asarray([max_samples_used, @@ -668,7 +668,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): y_corner = y[indices[1]] ax[m].plot([x_corner, x_corner], [y_corner, y_corner], [self.central_envelope[ - m, 1, indices[0], indices[1]], + m, 1, indices[0], indices[1]], self.central_envelope[ m, 0, indices[0], indices[1]]], color=self.colormap(self.boxcol)) diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering_plots.py index dbb11a35d..faf32cae8 100644 --- a/skfda/exploratory/visualization/clustering_plots.py +++ b/skfda/exploratory/visualization/clustering_plots.py @@ -43,11 +43,11 @@ def _change_luminosity(color, amount=0.5): def _darken(color, amount=0): - return _change_luminosity(color, 0.5 - amount/2) + return _change_luminosity(color, 0.5 - amount / 2) def _lighten(color, amount=0): - return _change_luminosity(color, 0.5 + amount/2) + return _change_luminosity(color, 0.5 + amount / 2) def _check_if_estimator(estimator): diff --git a/skfda/misc/_math.py b/skfda/misc/_math.py index 8891316d3..589034633 100644 --- a/skfda/misc/_math.py +++ b/skfda/misc/_math.py @@ -197,5 +197,5 @@ def inner_product(fdatagrid, fdatagrid2): fdatagrid.data_matrix[i, ..., 0] * fdatagrid2.data_matrix[j, ..., 0], x=fdatagrid.sample_points[0] - )) + )) return matrix diff --git a/skfda/misc/kernels.py b/skfda/misc/kernels.py index 3831b5d4f..9895c3b48 100644 --- a/skfda/misc/kernels.py +++ b/skfda/misc/kernels.py @@ -53,10 +53,10 @@ def epanechnikov(u): """ if isinstance(u, np.ndarray): res = np.zeros(u.shape) - res[abs(u) <= 1] = 0.75*(1 - u[abs(u) <= 1] ** 2) + res[abs(u) <= 1] = 0.75 * (1 - u[abs(u) <= 1] ** 2) return res if abs(u) <= 1: - return 0.75 * (1-u ** 2) + return 0.75 * (1 - u ** 2) return 0 diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 6cd587a63..e5a434c45 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -485,7 +485,7 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): penalty = np.square(penalty, out=penalty) penalty = scipy.integrate.simps(penalty, x=eval_points_normalized) - distance = np.sqrt(distance**2 + lam*penalty) + distance = np.sqrt(distance**2 + lam * penalty) return distance diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index ac4be370e..e141ba173 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -121,8 +121,8 @@ def _init_centroids(self, fdatagrid, random_state): """ comparison = True while comparison: - indices = random_state.permutation(fdatagrid.nsamples)[ - :self.n_clusters] + indices = (random_state.permutation(fdatagrid.nsamples)[ + :self.n_clusters]) centers = fdatagrid.data_matrix[indices] unique_centers = np.unique(centers, axis=0) comparison = len(unique_centers) != self.n_clusters @@ -696,8 +696,8 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): distances_to_centers = self.metric( fdata1=fdatagrid, fdata2=centers_fd) - distances_to_centers_raised = distances_to_centers ** ( - 2 / (self.fuzzifier - 1)) + distances_to_centers_raised = (distances_to_centers ** + (2 / (self.fuzzifier - 1))) for i in range(fdatagrid.nsamples): comparison = (fdatagrid.data_matrix[i] == centers).all( diff --git a/skfda/preprocessing/registration/_elastic.py b/skfda/preprocessing/registration/_elastic.py index b43ddd780..7209a1ad7 100644 --- a/skfda/preprocessing/registration/_elastic.py +++ b/skfda/preprocessing/registration/_elastic.py @@ -442,7 +442,7 @@ def warping_mean(warping, *, iter=20, tol=1e-5, step_size=1., eval_points=None, eval_points = _normalize_scale(eval_points) warping = FDataGrid(_normalize_scale(warping.data_matrix[..., 0]), - sample_points=_normalize_scale(warping.sample_points[0])) + _normalize_scale(warping.sample_points[0])) psi = to_srsf(warping, eval_points=eval_points).data_matrix[..., 0].T mu = to_srsf(warping.mean(), eval_points=eval_points).data_matrix[0] @@ -482,7 +482,7 @@ def warping_mean(warping, *, iter=20, tol=1e-5, step_size=1., eval_points=None, break # Update of mu - mu *= np.cos(step_size*v_norm) + mu *= np.cos(step_size * v_norm) vmean += np.sin(step_size * v_norm) / v_norm mu += vmean.T @@ -645,10 +645,11 @@ def elastic_mean(fdatagrid, *, lam=0., center=True, iter=20, tol=1e-3, # Gamma mean in Hilbert Sphere mean_normalized = warping_mean(gammas, return_shooting=False, **kwargs) - gamma_mean = FDataGrid(_normalize_scale(mean_normalized.data_matrix[..., 0], - a=eval_points[0], - b=eval_points[-1]), - sample_points=eval_points) + gamma_mean = FDataGrid(_normalize_scale( + mean_normalized.data_matrix[..., 0], + a=eval_points[0], + b=eval_points[-1]), + sample_points=eval_points) gamma_inverse = invert_warping(gamma_mean) diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index 926b54670..5cca24805 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -192,7 +192,7 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, eval_points)) # mse due to phase variation - mse_pha = scipy.integrate.simps(cr*eta_fine_sq - mu_fine_sq, eval_points) + mse_pha = scipy.integrate.simps(cr * eta_fine_sq - mu_fine_sq, eval_points) # mse due to amplitude variation # mse_amp = mse_total - mse_pha diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index df734618b..39a030d78 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -118,7 +118,7 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, eval_points = fd.sample_points[0] nfine = len(eval_points) except AttributeError: - nfine = max(fd.nbasis*10+1, 201) + nfine = max(fd.nbasis * 10 + 1, 201) eval_points = np.linspace(*domain_range, nfine) else: diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 5679974ee..1eb410451 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -225,10 +225,10 @@ def _numerical_penalty(self, coefficients): )[0] for j in range(i + 1, self.nbasis): penalty_matrix[i, j] = scipy.integrate.quad( - lambda x: (self._evaluate_single_basis_coefficients( - coefficients, i, x, cache) * - self._evaluate_single_basis_coefficients( - coefficients, j, x, cache)), + (lambda x: (self._evaluate_single_basis_coefficients( + coefficients, i, x, cache) * + self._evaluate_single_basis_coefficients( + coefficients, j, x, cache))), domain_range[0], domain_range[1] )[0] penalty_matrix[j, i] = penalty_matrix[i, j] @@ -651,7 +651,7 @@ def _compute_matrix(self, eval_points, derivative=0): def _derivative(self, coefs, order=1): return (Monomial(self.domain_range, self.nbasis - order), np.array([np.polyder(x[::-1], order)[::-1] - for x in coefs])) + for x in coefs])) def penalty(self, derivative_degree=None, coefficients=None): r"""Return a penalty matrix given a differential operator. @@ -738,9 +738,9 @@ def penalty(self, derivative_degree=None, coefficients=None): ipow = ibasis + jbasis - 2 * derivative_degree + 1 # coefficient after integrating penalty_matrix[ibasis, jbasis] = ( - (integration_domain[1] ** ipow - - integration_domain[0] ** ipow) - * ifac * jfac / ipow) + ((integration_domain[1] ** ipow) - + (integration_domain[0] ** ipow)) * + ifac * jfac / ipow) penalty_matrix[jbasis, ibasis] = penalty_matrix[ibasis, jbasis] @@ -1048,12 +1048,12 @@ def penalty(self, derivative_degree=None, coefficients=None): coeffs = pp.copy() for j in range(self.order - 1): coeffs[j + 1:] += ( - (binom(self.order - j - 1, - range(1, self.order - j)) * - np.vstack([(-a) ** - np.array(range(1, self.order - j)) - for a in self.knots[:-1]])).T * - pp[j]) + (binom(self.order - j - 1, + range(1, self.order - j)) * + np.vstack([(-a) ** + np.array(range(1, self.order - j)) + for a in self.knots[:-1]])).T * + pp[j]) ppoly_lst.append(coeffs) c[i] = 0 From 5aaca5f1cdbc0567293e54813366ea235c6723f4 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 23:01:23 +0200 Subject: [PATCH 102/222] More pep8 finished --- setup.cfg | 3 +++ skfda/misc/_lfd.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index bb0f4257a..3cd7fba87 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,3 +5,6 @@ test=pytest addopts = --doctest-modules doctest_optionflags = NORMALIZE_WHITESPACE ELLIPSIS norecursedirs = '.*', 'build', 'dist' '*.egg' 'venv' .svn _build docs/auto_examples examples + +[flake8] +ignore = F401,W504,W503 diff --git a/skfda/misc/_lfd.py b/skfda/misc/_lfd.py index e52478487..cf02b19e4 100644 --- a/skfda/misc/_lfd.py +++ b/skfda/misc/_lfd.py @@ -98,6 +98,6 @@ def __repr__(self): def __eq__(self, other): """Equality of Lfd objects""" - return (self.order == other.nderic - and all(self.weights[i] == other.bwtlist[i] - for i in range(self.order))) + return (self.order == other.nderic and + all(self.weights[i] == other.bwtlist[i] + for i in range(self.order))) From 0c8dbc998855162d14258af71427313fc5994913 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Fri, 21 Jun 2019 23:16:34 +0200 Subject: [PATCH 103/222] Finished PEP8 fixes --- skfda/ml/clustering/base_kmeans.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index e141ba173..b0bbefacd 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -121,8 +121,8 @@ def _init_centroids(self, fdatagrid, random_state): """ comparison = True while comparison: - indices = (random_state.permutation(fdatagrid.nsamples)[ - :self.n_clusters]) + indices = random_state.permutation(fdatagrid.nsamples)[ + :self.n_clusters] centers = fdatagrid.data_matrix[indices] unique_centers = np.unique(centers, axis=0) comparison = len(unique_centers) != self.n_clusters @@ -354,8 +354,8 @@ class KMeans(BaseKMeans): >>> kmeans.fit(fd, init=init_fd) >>> kmeans KMeans(max_iter=100, - metric=.pairwise - at 0x7faf3aa061e0>, # doctest:+ELLIPSIS + metric=.pairwise at + 0x7faf3aa061e0>, # doctest:+ELLIPSIS n_clusters=2, random_state=0, tol=0.0001) """.replace('+IGNORE_RESULT', '+ELLIPSIS\n<...>') @@ -481,10 +481,9 @@ def fit(self, X, y=None, sample_weight=None): index_best_iter = np.argmin(inertia) self.labels_ = clustering_values[index_best_iter] - self.cluster_centers_ = FDataGrid( - data_matrix=centers[index_best_iter], - sample_points=fdatagrid.sample_points - ) + self.cluster_centers_ = FDataGrid(data_matrix=centers[index_best_iter], + sample_points=fdatagrid.sample_points + ) self._distances_to_centers = distances_to_centers[index_best_iter] self.inertia_ = inertia[index_best_iter] self.n_iter_ = n_iter[index_best_iter] @@ -696,15 +695,15 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): distances_to_centers = self.metric( fdata1=fdatagrid, fdata2=centers_fd) - distances_to_centers_raised = (distances_to_centers ** - (2 / (self.fuzzifier - 1))) + distances_to_centers_raised = (distances_to_centers ** ( + 2 / (self.fuzzifier - 1))) for i in range(fdatagrid.nsamples): comparison = (fdatagrid.data_matrix[i] == centers).all( axis=tuple(np.arange(fdatagrid.data_matrix.ndim)[1:])) if comparison.sum() >= 1: - U[i, np.where(comparison is True)] = 1 - U[i, np.where(comparison is False)] = 0 + U[i, np.where(comparison == True)] = 1 + U[i, np.where(comparison == False)] = 0 else: for j in range(self.n_clusters): U[i, j] = 1 / np.sum( From 24ec6df3744c0feba4c5eddc2c212fdf96e6aad9 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Sat, 22 Jun 2019 02:12:02 +0200 Subject: [PATCH 104/222] Allow changing output points in the smoothing. --- .../smoothing/kernel_smoothers.py | 172 +++++++++++++++--- 1 file changed, 150 insertions(+), 22 deletions(-) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 995883e32..6b4739c18 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -28,17 +28,20 @@ def _check_r_to_r(f): class _LinearKernelSmoother(abc.ABC, BaseEstimator, TransformerMixin): def __init__(self, *, smoothing_parameter=None, - kernel=kernels.normal, weights=None): + kernel=kernels.normal, weights=None, + output_points=None): self.smoothing_parameter = smoothing_parameter self.kernel = kernel self.weights = weights + self.output_points = output_points self._cv = False # For testing purposes only - def _hat_matrix_function(self, *, input_points, smoothing_parameter, + def _hat_matrix_function(self, *, input_points, output_points, + smoothing_parameter, kernel, weights, _cv=False): # Time deltas - delta_x = np.abs(np.subtract.outer(input_points, input_points)) + delta_x = np.abs(np.subtract.outer(output_points, input_points)) # Obtain the non-normalized matrix matrix = self._hat_matrix_function_not_normalized( @@ -74,9 +77,13 @@ def fit(self, X: FDataGrid, y=None): _check_r_to_r(X) self.input_points_ = X.sample_points[0] + self.output_points_ = (self.output_points + if self.output_points is not None + else self.input_points_) self.hat_matrix_ = self._hat_matrix_function( input_points=self.input_points_, + output_points=self.output_points_, smoothing_parameter=self.smoothing_parameter, kernel=self.kernel, weights=self.weights, @@ -89,12 +96,14 @@ def transform(self, X: FDataGrid, y=None): assert all(self.input_points_ == X.sample_points[0]) - return X.copy(data_matrix=self.hat_matrix_ @ X.data_matrix) + return X.copy(data_matrix=self.hat_matrix_ @ X.data_matrix, + sample_points=self.output_points_) class NadarayaWatsonSmoother(_LinearKernelSmoother): r"""Nadaraya-Watson smoothing method. + It is a linear kernel smoothing method. Uses an smoothing matrix :math:`\hat{H}` for the discretisation points in argvals by the Nadaraya-Watson estimator. The smoothed values :math:`\hat{Y}` can be calculated as :math:`\hat{ @@ -117,11 +126,20 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): kernel. weights (ndarray, optional): Case weights matrix (in order to modify the importance of each point). + output_points (ndarray, optional): The output points. If ommited, + the input points are used. Examples: + >>> fd = FDataGrid(sample_points=[1, 2, 4, 5, 7], + ... data_matrix=[[1, 2, 3, 4, 5]]) >>> smoother = NadarayaWatsonSmoother(smoothing_parameter=3.5) - >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], - ... data_matrix=[[0,0,0,0,0]])) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 2.42], + [ 2.61], + [ 3.03], + [ 3.24], + [ 3.65]]]) >>> smoother.hat_matrix_.round(3) array([[ 0.294, 0.282, 0.204, 0.153, 0.068], [ 0.249, 0.259, 0.22 , 0.179, 0.093], @@ -129,8 +147,13 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 0.129, 0.172, 0.239, 0.249, 0.211], [ 0.073, 0.115, 0.221, 0.271, 0.319]]) >>> smoother = NadarayaWatsonSmoother(smoothing_parameter=2) - >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], - ... data_matrix=[[0,0,0,0,0]])) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.84], + [ 2.18], + [ 3.09], + [ 3.55], + [ 4.28]]]) >>> smoother.hat_matrix_.round(3) array([[ 0.425, 0.375, 0.138, 0.058, 0.005], [ 0.309, 0.35 , 0.212, 0.114, 0.015], @@ -138,6 +161,29 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 0.046, 0.11 , 0.299, 0.339, 0.206], [ 0.006, 0.022, 0.163, 0.305, 0.503]]) + The output points can be changed: + + >>> smoother = NadarayaWatsonSmoother( + ... smoothing_parameter=2, + ... output_points=[1, 2, 3, 4, 5, 6, 7]) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.84], + [ 2.18], + [ 2.61], + [ 3.09], + [ 3.55], + [ 3.95], + [ 4.28]]]) + >>> smoother.hat_matrix_.round(3) + array([[ 0.425, 0.375, 0.138, 0.058, 0.005], + [ 0.309, 0.35 , 0.212, 0.114, 0.015], + [ 0.195, 0.283, 0.283, 0.195, 0.043], + [ 0.103, 0.193, 0.319, 0.281, 0.103], + [ 0.046, 0.11 , 0.299, 0.339, 0.206], + [ 0.017, 0.053, 0.238, 0.346, 0.346], + [ 0.006, 0.022, 0.163, 0.305, 0.503]]) + """ def _hat_matrix_function_not_normalized(self, *, delta_x, smoothing_parameter, @@ -152,6 +198,7 @@ def _hat_matrix_function_not_normalized(self, *, delta_x, class LocalLinearRegressionSmoother(_LinearKernelSmoother): r"""Local linear regression smoothing method. + It is a linear kernel smoothing method. Uses an smoothing matrix :math:`\hat{H}` for the discretisation points in argvals by the local linear regression estimator. The smoothed values :math:`\hat{Y}` can be calculated as :math:`\hat{ @@ -179,11 +226,20 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): kernel. weights (ndarray, optional): Case weights matrix (in order to modify the importance of each point). + output_points (ndarray, optional): The output points. If ommited, + the input points are used. Examples: + >>> fd = FDataGrid(sample_points=[1, 2, 4, 5, 7], + ... data_matrix=[[1, 2, 3, 4, 5]]) >>> smoother = LocalLinearRegressionSmoother(smoothing_parameter=3.5) - >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], - ... data_matrix=[[0,0,0,0,0]])) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.13], + [ 1.36], + [ 3.29], + [ 4.27], + [ 5.08]]]) >>> smoother.hat_matrix_.round(3) array([[ 0.614, 0.429, 0.077, -0.03 , -0.09 ], [ 0.381, 0.595, 0.168, -0. , -0.143], @@ -191,8 +247,13 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): [-0.147, -0.036, 0.392, 0.639, 0.152], [-0.095, -0.079, 0.117, 0.308, 0.75 ]]) >>> smoother = LocalLinearRegressionSmoother(smoothing_parameter=2) - >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], - ... data_matrix=[[0,0,0,0,0]])) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.11], + [ 1.41], + [ 3.31], + [ 4.04], + [ 5.04]]]) >>> smoother.hat_matrix_.round(3) array([[ 0.714, 0.386, -0.037, -0.053, -0.01 ], [ 0.352, 0.724, 0.045, -0.081, -0.04 ], @@ -200,21 +261,45 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): [-0.07 , -0.067, 0.36 , 0.716, 0.061], [-0.012, -0.032, -0.025, 0.154, 0.915]]) + The output points can be changed: + + >>> smoother = LocalLinearRegressionSmoother( + ... smoothing_parameter=2, + ... output_points=[1, 2, 3, 4, 5, 6, 7]) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.11], + [ 1.41], + [ 1.81], + [ 3.31], + [ 4.04], + [ 5.35], + [ 5.04]]]) + >>> smoother.hat_matrix_.round(3) + array([[ 0.714, 0.386, -0.037, -0.053, -0.01 ], + [ 0.352, 0.724, 0.045, -0.081, -0.04 ], + [-0.084, 0.722, 0.722, -0.084, -0.278], + [-0.078, 0.052, 0.74 , 0.364, -0.078], + [-0.07 , -0.067, 0.36 , 0.716, 0.061], + [-0.098, -0.202, -0.003, 0.651, 0.651], + [-0.012, -0.032, -0.025, 0.154, 0.915]]) + """ def _hat_matrix_function_not_normalized(self, *, delta_x, smoothing_parameter, kernel): k = kernel(delta_x / smoothing_parameter) - s1 = np.sum(k * delta_x, 1) # S_n_1 - s2 = np.sum(k * delta_x ** 2, 1) # S_n_2 - b = (k * (s2 - delta_x * s1)).T # b_i(x_j) + s1 = np.sum(k * delta_x, 1, keepdims=True) # S_n_1 + s2 = np.sum(k * delta_x ** 2, 1, keepdims=True) # S_n_2 + b = (k * (s2 - delta_x * s1)) # b_i(x_j) return b class KNeighborsSmoother(_LinearKernelSmoother): """K-nearest neighbour kernel smoother. + It is a linear kernel smoothing method. Uses an smoothing matrix S for the discretisation points in argvals by the k nearest neighbours estimator. @@ -229,11 +314,21 @@ class KNeighborsSmoother(_LinearKernelSmoother): kernel to perform a 'usual' k nearest neighbours estimation. weights (ndarray, optional): Case weights matrix (in order to modify the importance of each point). + output_points (ndarray, optional): The output points. If ommited, + the input points are used. Examples: + >>> fd = FDataGrid(sample_points=[1, 2, 4, 5, 7], + ... data_matrix=[[1, 2, 3, 4, 5]]) >>> smoother = KNeighborsSmoother(smoothing_parameter=2) - >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,4,5,7], - ... data_matrix=[[0,0,0,0,0]])) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.5], + [ 1.5], + [ 3.5], + [ 3.5], + [ 4.5]]]) + >>> smoother.hat_matrix_.round(3) array([[ 0.5, 0.5, 0. , 0. , 0. ], [ 0.5, 0.5, 0. , 0. , 0. ], @@ -243,9 +338,16 @@ class KNeighborsSmoother(_LinearKernelSmoother): In case there are two points at the same distance it will take both. + >>> fd = FDataGrid(sample_points=[1, 2, 3, 5, 7], + ... data_matrix=[[1, 2, 3, 4, 5]]) >>> smoother = KNeighborsSmoother(smoothing_parameter=2) - >>> _ = smoother.fit(FDataGrid(sample_points=[1,2,3,5,7], - ... data_matrix=[[0,0,0,0,0]])) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.5], + [ 2. ], + [ 2.5], + [ 4. ], + [ 4.5]]]) >>> smoother.hat_matrix_.round(3) array([[ 0.5 , 0.5 , 0. , 0. , 0. ], [ 0.333, 0.333, 0.333, 0. , 0. ], @@ -253,13 +355,39 @@ class KNeighborsSmoother(_LinearKernelSmoother): [ 0. , 0. , 0.333, 0.333, 0.333], [ 0. , 0. , 0. , 0.5 , 0.5 ]]) + The output points can be changed: + + >>> smoother = KNeighborsSmoother( + ... smoothing_parameter=2, + ... output_points=[1, 2, 3, 4, 5, 6, 7]) + >>> fd_smoothed = smoother.fit_transform(fd) + >>> fd_smoothed.data_matrix.round(2) + array([[[ 1.5], + [ 2. ], + [ 2.5], + [ 3.5], + [ 4. ], + [ 4.5], + [ 4.5]]]) + + >>> smoother.hat_matrix_.round(3) + array([[ 0.5 , 0.5 , 0. , 0. , 0. ], + [ 0.333, 0.333, 0.333, 0. , 0. ], + [ 0. , 0.5 , 0.5 , 0. , 0. ], + [ 0. , 0. , 0.5 , 0.5 , 0. ], + [ 0. , 0. , 0.333, 0.333, 0.333], + [ 0. , 0. , 0. , 0.5 , 0.5 ], + [ 0. , 0. , 0. , 0.5 , 0.5 ]]) + """ def __init__(self, *, smoothing_parameter=None, - kernel=kernels.uniform, weights=None): + kernel=kernels.uniform, weights=None, + output_points=None): super().__init__( smoothing_parameter=smoothing_parameter, kernel=kernel, - weights=weights + weights=weights, + output_points=output_points ) def _hat_matrix_function_not_normalized(self, *, delta_x, @@ -281,7 +409,7 @@ def _hat_matrix_function_not_normalized(self, *, delta_x, # point within the k nearest neighbours vec = np.percentile(delta_x, smoothing_parameter / input_points_len * 100, - axis=0, interpolation='lower') + tol + axis=1, interpolation='lower') + tol rr = kernel((delta_x.T / vec).T) # Applies the kernel to the result of dividing each row by the result From 02ac7daec917f44cfabd345012c54f85d6742f09 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 22 Jun 2019 12:11:31 +0200 Subject: [PATCH 105/222] Renammed variables --- skfda/ml/regression/linear_model.py | 31 +++++++++++------------------ 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 71ec61de4..42ddcf7f1 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -8,6 +8,7 @@ class LinearScalarRegression(BaseEstimator, RegressorMixin): def __init__(self, beta, weights=None): + self.beta_ = None self.beta = beta self.weights = weights @@ -22,35 +23,32 @@ def fit(self, X, y=None): for j in range(nbeta): xcoef = X[j].coefficients - inner_x_beta = X[j].basis.inner_product(beta[j]) - Zmat = xcoef @ inner_x_beta if j == 0 else np.concatenate( - (Zmat, xcoef @ inner_x_beta), axis=1) + inner_basis_x_beta_j = X[j].basis.inner_product(beta[j]) + inner_x_beta = (xcoef @ inner_basis_x_beta_j if j == 0 else + np.concatenate( + (Zmat, xcoef @ inner_basis_x_beta_j), axis=1)) if any(w != 1 for w in weights): - rtwt = np.sqrt(weights) - Zmat = Zmat * rtwt - y = y * rtwt + inner_x_beta = inner_x_beta * np.sqrt(weights) + y = y * np.sqrt(weights) - Cmat = Zmat.T @ Zmat - Dmat = Zmat.T @ y + gram_inner_x_beta = inner_x_beta.T @ inner_x_beta + inner_x_beta_y = inner_x_beta.T @ y - Cmatinv = np.linalg.inv(Cmat) - betacoefs = Cmatinv @ Dmat + gram_inner_x_beta_inv = np.linalg.inv(gram_inner_x_beta) + betacoefs = gram_inner_x_beta @ inner_x_beta_y idx = 0 for j in range(0, nbeta): beta[j] = FDataBasis(beta[j], betacoefs[idx:beta[j].nbasis].T) idx = idx + beta[j].nbasis - self.beta = beta + self.beta_ = beta def predict(self, X): return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in range(len(self.beta))) for j in range(X[0].nsamples)] - def _mean_squared_error(self, y_actual, y_predicted): - return mean_squared_error(y_actual, y_predicted) - def _argcheck(self, y, x): """Do some checks to types and shapes""" if all(not isinstance(i, FData) for i in x): @@ -92,8 +90,3 @@ def _argcheck(self, y, x): raise ValueError("The weights should be non negative values") return y, x, self.beta, self.weights - - def score(self, X, y, sample_weight=None): - return self._mean_squared_error(y, self.predict(X)) - - From cc1f2011d2c3554cc387ddb2492c17167fe35347 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 22 Jun 2019 12:13:38 +0200 Subject: [PATCH 106/222] Some fixes --- skfda/ml/regression/linear_model.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 42ddcf7f1..1449af8af 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -24,9 +24,11 @@ def fit(self, X, y=None): for j in range(nbeta): xcoef = X[j].coefficients inner_basis_x_beta_j = X[j].basis.inner_product(beta[j]) - inner_x_beta = (xcoef @ inner_basis_x_beta_j if j == 0 else - np.concatenate( - (Zmat, xcoef @ inner_basis_x_beta_j), axis=1)) + inner_x_beta = (xcoef @ inner_basis_x_beta_j + if j == 0 + else np.concatenate((inner_x_beta, + xcoef @ inner_basis_x_beta_j), + axis=1)) if any(w != 1 for w in weights): inner_x_beta = inner_x_beta * np.sqrt(weights) @@ -36,7 +38,7 @@ def fit(self, X, y=None): inner_x_beta_y = inner_x_beta.T @ y gram_inner_x_beta_inv = np.linalg.inv(gram_inner_x_beta) - betacoefs = gram_inner_x_beta @ inner_x_beta_y + betacoefs = gram_inner_x_beta_inv @ inner_x_beta_y idx = 0 for j in range(0, nbeta): From 4797055d05d567215a9604386caed0093cb938b0 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 22 Jun 2019 15:20:49 +0200 Subject: [PATCH 107/222] Ommit for some functions not to be test --- setup.cfg | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/setup.cfg b/setup.cfg index 3cd7fba87..6ceed60a0 100644 --- a/setup.cfg +++ b/setup.cfg @@ -8,3 +8,10 @@ norecursedirs = '.*', 'build', 'dist' '*.egg' 'venv' .svn _build docs/auto_examp [flake8] ignore = F401,W504,W503 + +[coverage:run] +omit = + # Omit reporting for dataset module + */datasets/* + # Omit reporting for __init__.py files + */__init__.py From ed09dff0dd9add3f9da25f4930e9be41989f8401 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 22 Jun 2019 18:19:15 +0200 Subject: [PATCH 108/222] Making test more complete --- skfda/ml/regression/linear_model.py | 3 +- tests/test_regression.py | 108 +++++++++++++++++++++++++--- 2 files changed, 101 insertions(+), 10 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 1449af8af..99a2117d7 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -1,6 +1,5 @@ -from sklearn.metrics import mean_squared_error from sklearn.base import BaseEstimator, RegressorMixin -from skfda.representation.basis import * +from skfda.representation.basis import FDataBasis, Constant, Basis, FData import numpy as np diff --git a/tests/test_regression.py b/tests/test_regression.py index 6886335f7..550051a10 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -3,25 +3,31 @@ from skfda.ml.regression import LinearScalarRegression import numpy as np -class TestRegression(unittest.TestCase): - """Test regression""" - def test_linear_scalar_regression_auto(self): - beta_basis = Fourier(nbasis=5) - beta_fd = FDataBasis(beta_basis, [1, 2, 3, 4, 5]) +class TestLinearScalarRegression(unittest.TestCase): + + def test_regression_fit(self): x_basis = Monomial(nbasis=7) x_fd = FDataBasis(x_basis, np.identity(7)) - scalar_test = LinearScalarRegression([beta_fd]) - y = scalar_test.predict([x_fd]) + beta_basis = Fourier(nbasis=5) + beta_fd = FDataBasis(beta_basis, [1, 1, 1, 1, 1]) + y = [1.0000684777229512, + 0.1623672257830915, + 0.08521053851548224, + 0.08514200869281137, + 0.09529138749665378, + 0.10549625973303875, + 0.11384314859153018] scalar = LinearScalarRegression([beta_basis]) scalar.fit([x_fd], y) np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, beta_fd.coefficients) - def test_linear_scalar_regression(self): + + def test_regression_predict(self): x_basis = Monomial(nbasis=7) x_fd = FDataBasis(x_basis, np.identity(7)) @@ -41,6 +47,92 @@ def test_linear_scalar_regression(self): np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, beta_fd.coefficients) + def test_error_X_not_FData(self): + """Tests that at least one of the explanatory variables + is an FData object. """ + + x_fd = np.identity(7) + y = np.zeros(7) + + scalar = LinearScalarRegression([Fourier(nbasis=5)]) + + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + + def test_error_y_is_FData(self): + """Tests that none of the explained variables is an FData object + """ + x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + y = list(FDataBasis(Monomial(nbasis=7), np.identity(7))) + + scalar = LinearScalarRegression([Fourier(nbasis=5)]) + + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + + def test_error_X_beta_len_distinct(self): + """ Test that the number of beta bases and explanatory variables + are not different """ + + x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + y = [1 for _ in range(7)] + beta = Fourier(nbasis=5) + + scalar = LinearScalarRegression([beta]) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd, x_fd], y) + + scalar = LinearScalarRegression([beta, beta]) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + + def test_error_y_X_samples_different(self): + """ Test that the number of response samples and explanatory samples + are not different """ + + x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + y = [1 for _ in range(8)] + beta = Fourier(nbasis=5) + + scalar = LinearScalarRegression([beta]) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + + x_fd = FDataBasis(Monomial(nbasis=8), np.identity(8)) + y = [1 for _ in range(7)] + beta = Fourier(nbasis=5) + + scalar = LinearScalarRegression([beta]) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + + def test_error_beta_not_basis(self): + """ Test that all beta are Basis objects. """ + + x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + y = [1 for _ in range(7)] + beta = FDataBasis(Monomial(nbasis=7), np.identity(7)) + + scalar = LinearScalarRegression([beta]) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + + def test_error_weights_lenght(self): + """ Test that the number of weights is equal to the + number of samples """ + + x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + y = [1 for _ in range(7)] + weights = [1 for _ in range(8)] + beta = Monomial(nbasis=7) + + scalar = LinearScalarRegression([beta], weights) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + + def test_error_weights_negative(self): + """ Test that none of the weights are negative. """ + + x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + y = [1 for _ in range(7)] + weights = [-1 for _ in range(7)] + beta = Monomial(nbasis=7) + + scalar = LinearScalarRegression([beta], weights) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + if __name__ == '__main__': print() From 0717a1626e368a6c2bfd0730aa58d34b32ef1f46 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 23 Jun 2019 20:22:39 +0200 Subject: [PATCH 109/222] Fixes and more tests --- skfda/ml/regression/linear_model.py | 8 +++++-- tests/test_regression.py | 33 +++++++++++++++++++++++++---- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 99a2117d7..64d49c52f 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -11,7 +11,7 @@ def __init__(self, beta, weights=None): self.beta = beta self.weights = weights - def fit(self, X, y=None): + def fit(self, X, y): y, X, beta, weights = self._argcheck(y, X) @@ -41,15 +41,19 @@ def fit(self, X, y=None): idx = 0 for j in range(0, nbeta): - beta[j] = FDataBasis(beta[j], betacoefs[idx:beta[j].nbasis].T) + beta[j] = FDataBasis(beta[j], betacoefs[idx:idx+beta[j].nbasis].T) idx = idx + beta[j].nbasis self.beta_ = beta + return self def predict(self, X): return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in range(len(self.beta))) for j in range(X[0].nsamples)] + def fit_predict(self, X, y): + return self.fit(X, y).predict(X) + def _argcheck(self, y, x): """Do some checks to types and shapes""" if all(not isinstance(i, FData) for i in x): diff --git a/tests/test_regression.py b/tests/test_regression.py index 550051a10..c5fa78c43 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,5 +1,6 @@ import unittest -from skfda.representation.basis import Monomial, Fourier, FDataBasis +from skfda.representation.basis import (FDataBasis, Constant, Monomial, + Fourier, BSpline) from skfda.ml.regression import LinearScalarRegression import numpy as np @@ -26,8 +27,7 @@ def test_regression_fit(self): np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, beta_fd.coefficients) - - def test_regression_predict(self): + def test_regression_predict_single_explanatory(self): x_basis = Monomial(nbasis=7) x_fd = FDataBasis(x_basis, np.identity(7)) @@ -44,9 +44,34 @@ def test_regression_predict(self): scalar = LinearScalarRegression([beta_basis]) scalar.fit([x_fd], y) - np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, + np.testing.assert_array_almost_equal(scalar.beta_[0].coefficients, beta_fd.coefficients) + def test_regression_predict_multiple_explanatory(self): + y = [1, 2, 3, 4, 5, 6, 7] + + x0 = FDataBasis(Constant(domain_range=(0, 1)), np.ones((7, 1))) + x1 = FDataBasis(Monomial(nbasis=7), np.identity(7)) + + beta0 = Constant(domain_range=(0, 1)) + beta1 = BSpline(domain_range=(0, 1), nbasis=5) + + scalar = LinearScalarRegression([beta0, beta1]) + + scalar.fit([x0, x1], y) + + betas = scalar.beta_ + + np.testing.assert_array_almost_equal(betas[0].coefficients.round(4), + np.array([[32.6518]])) + + np.testing.assert_array_almost_equal(betas[1].coefficients.round(4), + np.array([[-28.6443, + 80.3996, + -188.587, + 236.5832, + -481.3449]])) + def test_error_X_not_FData(self): """Tests that at least one of the explanatory variables is an FData object. """ From 8172f98a303d79cca5bc71e4dbf17a1f1b315e42 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 24 Jun 2019 20:10:12 +0200 Subject: [PATCH 110/222] Add SmoothingParameterSearch instead of the previous function, so it can be used in a Pipeline --- docs/modules/preprocessing/smoothing.rst | 7 +- examples/plot_kernel_smoothing.py | 21 +-- .../smoothing/kernel_smoothers.py | 5 + skfda/preprocessing/smoothing/validation.py | 132 ++++++++++-------- tests/test_smoothing.py | 16 +-- 5 files changed, 106 insertions(+), 75 deletions(-) diff --git a/docs/modules/preprocessing/smoothing.rst b/docs/modules/preprocessing/smoothing.rst index 8426082ec..9a2b72e35 100644 --- a/docs/modules/preprocessing/smoothing.rst +++ b/docs/modules/preprocessing/smoothing.rst @@ -58,14 +58,15 @@ default one. The available ones are: skfda.preprocessing.smoothing.validation.shibata skfda.preprocessing.smoothing.validation.rice -An utility method is also provided, which calls the sckit-learn +An utility class is also provided, which inherits from the sckit-learn class :class:`~sklearn.model_selection.GridSearchCV` -object with the scorers to find the best smoothing parameters from a list. +and performs a grid search using the scorers to find the best +``smoothing_parameter`` from a list. .. autosummary:: :toctree: autosummary - skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter + skfda.preprocessing.smoothing.validation.SmoothingParameterSearch References diff --git a/examples/plot_kernel_smoothing.py b/examples/plot_kernel_smoothing.py index d84c91af8..bdaff55db 100644 --- a/examples/plot_kernel_smoothing.py +++ b/examples/plot_kernel_smoothing.py @@ -40,19 +40,22 @@ param_values = np.linspace(start=2, stop=25, num=24) # Local linear regression kernel smoothing. -llr = val.optimize_smoothing_parameter( - fd, param_values, smoothing_method=ks.LocalLinearRegressionSmoother()) -llr_fd = llr.best_estimator_.transform(fd) +llr = val.SmoothingParameterSearch( + ks.LocalLinearRegressionSmoother(), param_values) +llr.fit(fd) +llr_fd = llr.transform(fd) # Nadaraya-Watson kernel smoothing. -nw = skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter( - fd, param_values, smoothing_method=ks.NadarayaWatsonSmoother()) -nw_fd = nw.best_estimator_.transform(fd) +nw = val.SmoothingParameterSearch( + ks.NadarayaWatsonSmoother(), param_values) +nw.fit(fd) +nw_fd = nw.transform(fd) # K-nearest neighbours kernel smoothing. -knn = skfda.preprocessing.smoothing.validation.optimize_smoothing_parameter( - fd, param_values, smoothing_method=ks.KNeighborsSmoother()) -knn_fd = knn.best_estimator_.transform(fd) +knn = val.SmoothingParameterSearch( + ks.KNeighborsSmoother(), param_values) +knn.fit(fd) +knn_fd = knn.transform(fd) plt.plot(param_values, knn.cv_results_['mean_test_score'], label='k-nearest neighbors') diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 6b4739c18..b35821548 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -99,6 +99,11 @@ def transform(self, X: FDataGrid, y=None): return X.copy(data_matrix=self.hat_matrix_ @ X.data_matrix, sample_points=self.output_points_) + def score(self, X, y): + from .validation import LinearSmootherGeneralizedCVScorer + + return LinearSmootherGeneralizedCVScorer()(self, X, y) + class NadarayaWatsonSmoother(_LinearKernelSmoother): r"""Nadaraya-Watson smoothing method. diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 92d8f935b..9a2ba7907 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -99,9 +99,7 @@ def penalization_function(hat_matrix): * penalization_function(hat_matrix)) -def optimize_smoothing_parameter(fdatagrid, parameter_values, - smoothing_method=None, - cv_method=None): +class SmoothingParameterSearch(GridSearchCV): """Chooses the best smoothing parameter and performs smoothing. Performs the smoothing of a FDataGrid object choosing the best @@ -112,19 +110,42 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, data, using the cv_method as a scorer. Args: - fdatagrid (FDataGrid): FDataGrid object. - parameters (list of double): List of parameters to be tested. - smoothing_method (Function): Function that takes a list of - discretised points, a parameter, an optionally a weights matrix - and returns a hat matrix or smoothing matrix. - cv_method (Function): Function that takes a matrix, - a smoothing matrix, and optionally a weights matrix and - calculates a cross validation score. - penalization_function(Fuction): if gcv is selected as cv_method a - penalization function can be specified through this parameter. - - Returns: - grid: A scikit-learn GridSearchCV estimator, properly fitted. + estimator (smoother estimator): scikit-learn compatible smoother. + param_values (iterable): iterable containing the values to test + for *smoothing_parameter*. + scoring (scoring method): scoring method used to measure the + performance of the smoothing. + n_jobs (int or None, optional (default=None)): + Number of jobs to run in parallel. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` + context. ``-1`` means using all processors. See + :term:`scikit-learn Glossary ` for more details. + + pre_dispatch (int, or string, optional): + Controls the number of jobs that get dispatched during parallel + execution. Reducing this number can be useful to avoid an + explosion of memory consumption when more jobs get dispatched + than CPUs can process. This parameter can be: + + - None, in which case all the jobs are immediately + created and spawned. Use this for lightweight and + fast-running jobs, to avoid delays due to on-demand + spawning of the jobs + + - An int, giving the exact number of total jobs that are + spawned + + - A string, giving an expression as a function of n_jobs, + as in '2*n_jobs' + verbose (integer): + Controls the verbosity: the higher, the more messages. + + error_score ('raise' or numeric): + Value to assign to the score if an error occurs in estimator + fitting. If set to 'raise', the error is raised. If a numeric + value is given, FitFailedWarning is raised. This parameter does + not affect the refit step, which will always raise the error. + Default is np.nan. Examples: Creates a FDataGrid object of the function :math:`y=x^2` and peforms @@ -133,8 +154,9 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, >>> import skfda >>> x = np.linspace(-2, 2, 5) >>> fd = skfda.FDataGrid(x ** 2, x) - >>> grid = optimize_smoothing_parameter(fd, [2,3], - ... smoothing_method=kernel_smoothers.KNeighborsSmoother()) + >>> grid = SmoothingParameterSearch( + ... kernel_smoothers.KNeighborsSmoother(), [2,3]) + >>> _ = grid.fit(fd) >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-11.67, -12.37]) >>> round(grid.best_score_, 2) @@ -147,7 +169,7 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, [ 0. , 0.33, 0.33, 0.33, 0. ], [ 0. , 0. , 0.33, 0.33, 0.33], [ 0. , 0. , 0. , 0.5 , 0.5 ]]) - >>> grid.best_estimator_.transform(fd).round(2) + >>> grid.transform(fd).round(2) FDataGrid( array([[[ 2.5 ], [ 1.67], @@ -161,56 +183,56 @@ def optimize_smoothing_parameter(fdatagrid, parameter_values, Other validation methods can be used such as cross-validation or general cross validation using other penalization functions. - >>> grid = optimize_smoothing_parameter(fd, [2,3], - ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), - ... cv_method=LinearSmootherLeaveOneOutScorer()) + >>> grid = SmoothingParameterSearch( + ... kernel_smoothers.KNeighborsSmoother(), [2,3], + ... scoring=LinearSmootherLeaveOneOutScorer()) + >>> _ = grid.fit(fd) >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-4.2, -5.5]) - >>> grid = optimize_smoothing_parameter(fd, [2,3], - ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), - ... cv_method=LinearSmootherGeneralizedCVScorer( + >>> grid = SmoothingParameterSearch( + ... kernel_smoothers.KNeighborsSmoother(), [2,3], + ... scoring=LinearSmootherGeneralizedCVScorer( ... akaike_information_criterion)) + >>> _ = grid.fit(fd) >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([ -9.35, -10.71]) - >>> grid = optimize_smoothing_parameter(fd, [2,3], - ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), - ... cv_method=LinearSmootherGeneralizedCVScorer( + >>> grid = SmoothingParameterSearch( + ... kernel_smoothers.KNeighborsSmoother(), [2,3], + ... scoring=LinearSmootherGeneralizedCVScorer( ... finite_prediction_error)) + >>> _ = grid.fit(fd) >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([ -9.8, -11. ]) - >>> grid = optimize_smoothing_parameter(fd, [2,3], - ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), - ... cv_method=LinearSmootherGeneralizedCVScorer(shibata)) + >>> grid = SmoothingParameterSearch( + ... kernel_smoothers.KNeighborsSmoother(), [2,3], + ... scoring=LinearSmootherGeneralizedCVScorer(shibata)) + >>> _ = grid.fit(fd) >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-7.56, -9.17]) - >>> grid = optimize_smoothing_parameter(fd, [2,3], - ... smoothing_method=kernel_smoothers.KNeighborsSmoother(), - ... cv_method=LinearSmootherGeneralizedCVScorer(rice)) + >>> grid = SmoothingParameterSearch( + ... kernel_smoothers.KNeighborsSmoother(), [2,3], + ... scoring=LinearSmootherGeneralizedCVScorer(rice)) + >>> _ = grid.fit(fd) >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-21. , -16.5]) """ - if fdatagrid.ndim_domain != 1: - raise NotImplementedError("This method only works when the dimension " - "of the domain of the FDatagrid object is " - "one.") - if fdatagrid.ndim_image != 1: - raise NotImplementedError("This method only works when the dimension " - "of the image of the FDatagrid object is " - "one.") - - if smoothing_method is None: - smoothing_method = kernel_smoothers.NadarayaWatsonSmoother() - - if cv_method is None: - cv_method = LinearSmootherGeneralizedCVScorer() - - grid = GridSearchCV(estimator=smoothing_method, - param_grid={'smoothing_parameter': parameter_values}, - scoring=cv_method, cv=[(slice(None), slice(None))]) - grid.fit(fdatagrid, fdatagrid) - - return grid + + def __init__(self, estimator, param_values, *, scoring=None, n_jobs=None, + verbose=0, pre_dispatch='2*n_jobs', + error_score=np.nan): + super().__init__(estimator=estimator, scoring=scoring, + param_grid={'smoothing_parameter': param_values}, + n_jobs=n_jobs, + refit=True, cv=[(slice(None), slice(None))], + verbose=verbose, pre_dispatch=pre_dispatch, + error_score=error_score, return_train_score=False) + self.estimator = estimator + self.param_values = param_values + self._scoring = scoring + + def fit(self, X, y=None, groups=None, **fit_params): + return GridSearchCV.fit(self, X, y=X, groups=groups, **fit_params) def akaike_information_criterion(hat_matrix): diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py index 94a1596b4..76165e3a0 100644 --- a/tests/test_smoothing.py +++ b/tests/test_smoothing.py @@ -42,16 +42,16 @@ def _test_generic(self, estimator_class): estimator = estimator_class() - grid = validation.optimize_smoothing_parameter( - fd, [2, 3], - smoothing_method=estimator, - cv_method=loo_scorer) + grid = validation.SmoothingParameterSearch( + estimator, [2, 3], + scoring=loo_scorer) + grid.fit(fd) score = np.array(grid.cv_results_['mean_test_score']) - grid_alt = validation.optimize_smoothing_parameter( - fd, [2, 3], - smoothing_method=estimator, - cv_method=loo_scorer_alt) + grid_alt = validation.SmoothingParameterSearch( + estimator, [2, 3], + scoring=loo_scorer_alt) + grid_alt.fit(fd) score_alt = np.array(grid_alt.cv_results_['mean_test_score']) np.testing.assert_array_almost_equal(score, score_alt) From 9fb95ba24a34720d76d1b1905cca1b7e059689a4 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Tue, 25 Jun 2019 07:27:17 +0200 Subject: [PATCH 111/222] Some fixes --- skfda/ml/regression/linear_model.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 64d49c52f..fec56eea1 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -6,23 +6,22 @@ class LinearScalarRegression(BaseEstimator, RegressorMixin): - def __init__(self, beta, weights=None): + def __init__(self, beta_basis, weights=None): self.beta_ = None - self.beta = beta - self.weights = weights + self.beta_basis = beta_basis - def fit(self, X, y): + def fit(self, X, y=None, sample_weight=None): - y, X, beta, weights = self._argcheck(y, X) + y, X, weights = self._argcheck(y, X, sample_weight) - nbeta = len(beta) + nbeta = len(self.beta_basis) nsamples = X[0].nsamples - y = np.array(y).reshape((nsamples, 1)) + y = np.asarray(y).reshape((nsamples, 1)) for j in range(nbeta): xcoef = X[j].coefficients - inner_basis_x_beta_j = X[j].basis.inner_product(beta[j]) + inner_basis_x_beta_j = X[j].basis.inner_product(self.beta_basis[j]) inner_x_beta = (xcoef @ inner_basis_x_beta_j if j == 0 else np.concatenate((inner_x_beta, @@ -41,10 +40,10 @@ def fit(self, X, y): idx = 0 for j in range(0, nbeta): - beta[j] = FDataBasis(beta[j], betacoefs[idx:idx+beta[j].nbasis].T) - idx = idx + beta[j].nbasis + self.beta_basis[j] = FDataBasis(self.beta_basis[j], betacoefs[idx:idx+self.beta_basis[j].nbasis].T) + idx = idx + self.beta_basis[j].nbasis - self.beta_ = beta + self.beta_ = self.beta_basis return self def predict(self, X): @@ -54,7 +53,7 @@ def predict(self, X): def fit_predict(self, X, y): return self.fit(X, y).predict(X) - def _argcheck(self, y, x): + def _argcheck(self, y, x, sample_weight): """Do some checks to types and shapes""" if all(not isinstance(i, FData) for i in x): raise ValueError("All the dependent variable are scalar.") @@ -94,4 +93,4 @@ def _argcheck(self, y, x): if np.any(np.array(self.weights) < 0): raise ValueError("The weights should be non negative values") - return y, x, self.beta, self.weights + return y, x, sample_weight From 1f141c8ae4379e83c010fb1a373b8f93424ebbf7 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Tue, 25 Jun 2019 07:55:20 +0200 Subject: [PATCH 112/222] Remove the fit_predict function --- skfda/ml/regression/linear_model.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index fec56eea1..192a598e5 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -50,9 +50,6 @@ def predict(self, X): return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in range(len(self.beta))) for j in range(X[0].nsamples)] - def fit_predict(self, X, y): - return self.fit(X, y).predict(X) - def _argcheck(self, y, x, sample_weight): """Do some checks to types and shapes""" if all(not isinstance(i, FData) for i in x): From 10565debbc2f471be8307bbc3361880898632236 Mon Sep 17 00:00:00 2001 From: pablomm Date: Tue, 25 Jun 2019 10:45:09 +0200 Subject: [PATCH 113/222] numpy to np and changes requested --- skfda/representation/_functional_data.py | 5 +-- skfda/representation/basis.py | 4 +- skfda/representation/grid.py | 16 +++----- tests/test_grid.py | 48 ++++++++++++------------ 4 files changed, 33 insertions(+), 40 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index a562590d6..8d544c802 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -42,7 +42,6 @@ def __init__(self, extrapolation, dataset_label, axes_labels, keepdims): self.dataset_label = dataset_label self.axes_labels = axes_labels self.keepdims = keepdims - self._coordinates = None @property def axes_labels(self): @@ -111,9 +110,9 @@ def ndim_codomain(self): def coordinates(self): r"""Return a component of the FDataGrid. - If the functional object contains samples + If the functional object contains multivariate samples :math:`f: \mathbb{R}^n \rightarrow \mathbb{R}^d`, this method returns - a component of the vector :math:`f = (f_1, ..., f_d)`. + an iterator of the vector :math:`f = (f_1, ..., f_d)`. """ pass diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index ef70ab7aa..12c32f33a 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1832,10 +1832,8 @@ def coordinates(self): form. """ - if self._coordinates is None: - self._coordinates = FDataBasis._CoordinateIterator(self) - return self._coordinates + return FDataBasis._CoordinateIterator(self) @property def nbasis(self): diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 57d1a4f06..ddba3bbdc 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -107,10 +107,8 @@ def __init__(self, fdatagrid): def __iter__(self): """Return an iterator through the image coordinates.""" - for k in range(len(self)): - yield self._fdatagrid.copy( - data_matrix=self._fdatagrid.data_matrix[..., k], - axes_labels=self._fdatagrid._get_labels_coordinates(k)) + for i in range(len(self)): + yield self[i] def __getitem__(self, key): """Get a specific coordinate.""" @@ -301,10 +299,8 @@ def coordinates(self): 3 """ - if self._coordinates is None: - self._coordinates = FDataGrid._CoordinateIterator(self) - return self._coordinates + return FDataGrid._CoordinateIterator(self) @property def ndim(self): @@ -740,7 +736,7 @@ def concatenate(self, *others, as_coordinates=False): for other in others: self.__check_same_dimensions(other) - elif not all([numpy.array_equal(self.sample_points, other.sample_points) + elif not all([np.array_equal(self.sample_points, other.sample_points) for other in others]): raise ValueError("All the FDataGrids must be sampled in the same " "sample points.") @@ -757,11 +753,11 @@ def concatenate(self, *others, as_coordinates=False): if as_coordinates: - return self.copy(data_matrix=numpy.concatenate(data, axis=-1), + return self.copy(data_matrix=np.concatenate(data, axis=-1), axes_labels=self._join_labels_coordinates(*others)) else: - return self.copy(data_matrix=numpy.concatenate(data, axis=0)) + return self.copy(data_matrix=np.concatenate(data, axis=0)) def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): diff --git a/tests/test_grid.py b/tests/test_grid.py index 5f250e66f..e03860b86 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -61,13 +61,13 @@ def test_concatenate(self): fd1.axes_labels = ["x", "y"] fd = fd1.concatenate(fd2) - numpy.testing.assert_equal(fd.nsamples, 4) - numpy.testing.assert_equal(fd.ndim_image, 1) - numpy.testing.assert_equal(fd.ndim_domain, 1) - numpy.testing.assert_array_equal(fd.data_matrix[..., 0], + np.testing.assert_equal(fd.nsamples, 4) + np.testing.assert_equal(fd.ndim_image, 1) + np.testing.assert_equal(fd.ndim_domain, 1) + np.testing.assert_array_equal(fd.data_matrix[..., 0], [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) - numpy.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) + np.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) def test_concatenate(self): fd1 = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) @@ -76,13 +76,13 @@ def test_concatenate(self): fd1.axes_labels = ["x", "y"] fd = fd1.concatenate(fd2) - numpy.testing.assert_equal(fd.nsamples, 4) - numpy.testing.assert_equal(fd.ndim_image, 1) - numpy.testing.assert_equal(fd.ndim_domain, 1) - numpy.testing.assert_array_equal(fd.data_matrix[..., 0], + np.testing.assert_equal(fd.nsamples, 4) + np.testing.assert_equal(fd.ndim_image, 1) + np.testing.assert_equal(fd.ndim_domain, 1) + np.testing.assert_array_equal(fd.data_matrix[..., 0], [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) - numpy.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) + np.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) def test_concatenate_coordinates(self): fd1 = FDataGrid([[1, 2, 3, 4], [2, 3, 4, 5]]) @@ -92,23 +92,23 @@ def test_concatenate_coordinates(self): fd2.axes_labels = ["w", "t"] fd = fd1.concatenate(fd2, as_coordinates=True) - numpy.testing.assert_equal(fd.nsamples, 2) - numpy.testing.assert_equal(fd.ndim_image, 2) - numpy.testing.assert_equal(fd.ndim_domain, 1) + np.testing.assert_equal(fd.nsamples, 2) + np.testing.assert_equal(fd.ndim_image, 2) + np.testing.assert_equal(fd.ndim_domain, 1) - numpy.testing.assert_array_equal(fd.data_matrix, + np.testing.assert_array_equal(fd.data_matrix, [[[1, 3], [2, 4], [3, 5], [4, 6]], [[2, 4], [3, 5], [4, 6], [5, 7]]]) # Testing labels - numpy.testing.assert_array_equal(["x", "y", "t"], fd.axes_labels) + np.testing.assert_array_equal(["x", "y", "t"], fd.axes_labels) fd1.axes_labels = ["x", "y"] fd2.axes_labels = None fd = fd1.concatenate(fd2, as_coordinates=True) - numpy.testing.assert_array_equal(["x", "y", None], fd.axes_labels) + np.testing.assert_array_equal(["x", "y", None], fd.axes_labels) fd1.axes_labels = None fd = fd1.concatenate(fd2, as_coordinates=True) - numpy.testing.assert_equal(None, fd.axes_labels) + np.testing.assert_equal(None, fd.axes_labels) def test_coordinates(self): fd1 = FDataGrid([[1, 2, 3, 4], [2, 3, 4, 5]]) @@ -117,24 +117,24 @@ def test_coordinates(self): fd = fd1.concatenate(fd2, as_coordinates=True) # Indexing with number - numpy.testing.assert_array_equal(fd.coordinates[0].data_matrix, + np.testing.assert_array_equal(fd.coordinates[0].data_matrix, fd1.data_matrix) - numpy.testing.assert_array_equal(fd.coordinates[1].data_matrix, + np.testing.assert_array_equal(fd.coordinates[1].data_matrix, fd2.data_matrix) # Iteration for fd_j, fd_i in zip([fd1, fd2], fd.coordinates): - numpy.testing.assert_array_equal(fd_j.data_matrix, fd_i.data_matrix) + np.testing.assert_array_equal(fd_j.data_matrix, fd_i.data_matrix) fd3 = fd1.concatenate(fd2, fd1, fd, as_coordinates=True) # Multiple indexation - numpy.testing.assert_equal(fd3.ndim_image, 5) - numpy.testing.assert_array_equal(fd3.coordinates[:2].data_matrix, + np.testing.assert_equal(fd3.ndim_image, 5) + np.testing.assert_array_equal(fd3.coordinates[:2].data_matrix, fd.data_matrix) - numpy.testing.assert_array_equal(fd3.coordinates[-2:].data_matrix, + np.testing.assert_array_equal(fd3.coordinates[-2:].data_matrix, fd.data_matrix) - numpy.testing.assert_array_equal( + np.testing.assert_array_equal( fd3.coordinates[(False, False, True, False, True)].data_matrix, fd.data_matrix) From cda3fb0a3f97b4fdf58a2bcc8db9c43f50c81283 Mon Sep 17 00:00:00 2001 From: pablomm Date: Tue, 25 Jun 2019 11:15:32 +0200 Subject: [PATCH 114/222] Changed line in documentation --- skfda/representation/basis.py | 5 +++-- skfda/representation/grid.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 12c32f33a..a8a250659 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -2354,7 +2354,7 @@ def concatenate(self, *others, as_coordinates=False): basis. Args: - others (:class:`FDataBasis`): other FDataBasis objects. + others (:class:`FDataBasis`): Objects to be concatenated. as_coordinates (boolean, optional): If False concatenates as new samples, else, concatenates the other functions as new components of the image. Defaults to False. @@ -2365,9 +2365,10 @@ def concatenate(self, *others, as_coordinates=False): Todo: By the moment, only unidimensional objects are supported in basis - form. + representation. """ + # TODO: Change to support multivariate functions in basis representation if as_coordinates: return NotImplemented diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index ddba3bbdc..8487d2de3 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -701,7 +701,7 @@ def concatenate(self, *others, as_coordinates=False): dimensions and sampling points. Args: - others (:obj:`FDataGrid`): another FDataGrid object. + others (:obj:`FDataGrid`): Objects to be concatenated. as_coordinates (boolean, optional): If False concatenates as new samples, else, concatenates the other functions as new components of the image. Defaults to false. From 639cd3ed240e0bcc6f394f1f8b86561e8500c88d Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 25 Jun 2019 12:03:33 +0200 Subject: [PATCH 115/222] Changed validation methods to work well even if `output_points` is not None --- skfda/preprocessing/smoothing/validation.py | 48 +++++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 9a2ba7907..540eb93d1 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -3,12 +3,26 @@ from . import kernel_smoothers from sklearn.model_selection import GridSearchCV +import sklearn __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" +def _get_input_estimation_and_matrix(estimator, X): + """Returns the smoothed data evaluated at the input points & the matrix""" + if estimator.output_points is not None: + estimator = sklearn.base.clone(estimator) + estimator.output_points = None + estimator.fit(X) + y_est = estimator.transform(X) + + hat_matrix = estimator.hat_matrix_ + + return y_est, hat_matrix + + class LinearSmootherLeaveOneOutScorer(): r"""Leave-one-out cross validation scoring method for linear smoothers. @@ -42,9 +56,8 @@ class LinearSmootherLeaveOneOutScorer(): """ def __call__(self, estimator, X, y): - y_est = estimator.transform(X) - hat_matrix = estimator.hat_matrix_ + y_est, hat_matrix = _get_input_estimation_and_matrix(estimator, X) return -np.mean(((y.data_matrix[..., 0] - y_est.data_matrix[..., 0]) / (1 - hat_matrix.diagonal())) ** 2) @@ -84,9 +97,7 @@ def __init__(self, penalization_function=None): self.penalization_function = penalization_function def __call__(self, estimator, X, y): - y_est = estimator.transform(X) - - hat_matrix = estimator.hat_matrix_ + y_est, hat_matrix = _get_input_estimation_and_matrix(estimator, X) if self.penalization_function is None: def penalization_function(hat_matrix): @@ -216,6 +227,26 @@ class SmoothingParameterSearch(GridSearchCV): >>> np.array(grid.cv_results_['mean_test_score']).round(2) array([-21. , -16.5]) + Different output points can also be used. In that case the value used + as a target is still the smoothed value at the input points: + >>> output_points = np.linspace(-2, 2, 9) + >>> grid = SmoothingParameterSearch( + ... kernel_smoothers.KNeighborsSmoother( + ... output_points=output_points + ... ), [2,3]) + >>> _ = grid.fit(fd) + >>> np.array(grid.cv_results_['mean_test_score']).round(2) + array([-11.67, -12.37]) + >>> grid.transform(fd).data_matrix.round(2) + array([[[ 2.5 ], + [ 2.5 ], + [ 1.67], + [ 0.5 ], + [ 0.67], + [ 0.5 ], + [ 1.67], + [ 2.5 ], + [ 2.5 ]]]) """ def __init__(self, estimator, param_values, *, scoring=None, n_jobs=None, @@ -227,12 +258,13 @@ def __init__(self, estimator, param_values, *, scoring=None, n_jobs=None, refit=True, cv=[(slice(None), slice(None))], verbose=verbose, pre_dispatch=pre_dispatch, error_score=error_score, return_train_score=False) - self.estimator = estimator self.param_values = param_values - self._scoring = scoring def fit(self, X, y=None, groups=None, **fit_params): - return GridSearchCV.fit(self, X, y=X, groups=groups, **fit_params) + if y is None: + y = X + + return super().fit(X, y=y, groups=groups, **fit_params) def akaike_information_criterion(hat_matrix): From 7cdce0a73086871337879e6db01ccacaba78aba2 Mon Sep 17 00:00:00 2001 From: pablomm Date: Tue, 25 Jun 2019 12:18:31 +0200 Subject: [PATCH 116/222] Commit to rebuild travis. Pep8 indentation in test --- tests/test_grid.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/test_grid.py b/tests/test_grid.py index e03860b86..483a5cab5 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -65,8 +65,8 @@ def test_concatenate(self): np.testing.assert_equal(fd.ndim_image, 1) np.testing.assert_equal(fd.ndim_domain, 1) np.testing.assert_array_equal(fd.data_matrix[..., 0], - [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], - [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) + [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], + [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) np.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) def test_concatenate(self): @@ -80,8 +80,8 @@ def test_concatenate(self): np.testing.assert_equal(fd.ndim_image, 1) np.testing.assert_equal(fd.ndim_domain, 1) np.testing.assert_array_equal(fd.data_matrix[..., 0], - [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], - [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) + [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], + [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) np.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) def test_concatenate_coordinates(self): @@ -97,8 +97,8 @@ def test_concatenate_coordinates(self): np.testing.assert_equal(fd.ndim_domain, 1) np.testing.assert_array_equal(fd.data_matrix, - [[[1, 3], [2, 4], [3, 5], [4, 6]], - [[2, 4], [3, 5], [4, 6], [5, 7]]]) + [[[1, 3], [2, 4], [3, 5], [4, 6]], + [[2, 4], [3, 5], [4, 6], [5, 7]]]) # Testing labels np.testing.assert_array_equal(["x", "y", "t"], fd.axes_labels) @@ -118,9 +118,9 @@ def test_coordinates(self): # Indexing with number np.testing.assert_array_equal(fd.coordinates[0].data_matrix, - fd1.data_matrix) + fd1.data_matrix) np.testing.assert_array_equal(fd.coordinates[1].data_matrix, - fd2.data_matrix) + fd2.data_matrix) # Iteration for fd_j, fd_i in zip([fd1, fd2], fd.coordinates): @@ -131,15 +131,14 @@ def test_coordinates(self): # Multiple indexation np.testing.assert_equal(fd3.ndim_image, 5) np.testing.assert_array_equal(fd3.coordinates[:2].data_matrix, - fd.data_matrix) + fd.data_matrix) np.testing.assert_array_equal(fd3.coordinates[-2:].data_matrix, - fd.data_matrix) + fd.data_matrix) np.testing.assert_array_equal( fd3.coordinates[(False, False, True, False, True)].data_matrix, fd.data_matrix) - if __name__ == '__main__': print() unittest.main() From 0a3413b7076e28adc5bd28d4f49923be058257fd Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Tue, 25 Jun 2019 12:32:40 +0200 Subject: [PATCH 117/222] Updated travis status image Now it is shown the develop status. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 8f4303130..3f9367052 100644 --- a/README.rst +++ b/README.rst @@ -50,7 +50,7 @@ References .. _fda: http://www.functionaldata.org/ -.. |build-status| image:: https://travis-ci.org/GAA-UAM/scikit-fda.svg +.. |build-status| image:: https://travis-ci.org/GAA-UAM/scikit-fda.svg?branch=develop :alt: build status :scale: 100% :target: https://travis-ci.org/GAA-UAM/scikit-fda From f7f1313b51dbd283d451a205969d6d28c19dc80b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 25 Jun 2019 12:46:04 +0200 Subject: [PATCH 118/222] Documentation updated. --- .../smoothing/kernel_smoothers.py | 30 +++++++++++++++++++ skfda/preprocessing/smoothing/validation.py | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index b35821548..3552bd7eb 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -73,7 +73,16 @@ def _more_tags(self): } def fit(self, X: FDataGrid, y=None): + """Compute the hat matrix for the desired output points. + Args: + X (FDataGrid): + The data whose points are used to compute the matrix. + y : Ignored + Returns: + self (object) + + """ _check_r_to_r(X) self.input_points_ = X.sample_points[0] @@ -93,6 +102,16 @@ def fit(self, X: FDataGrid, y=None): return self def transform(self, X: FDataGrid, y=None): + """Multiplies the hat matrix for the functions values to smooth them. + + Args: + X (FDataGrid): + The data to smooth. + y : Ignored + Returns: + self (object) + + """ assert all(self.input_points_ == X.sample_points[0]) @@ -100,6 +119,17 @@ def transform(self, X: FDataGrid, y=None): sample_points=self.output_points_) def score(self, X, y): + """Returns the generalized cross validation (GCV) score. + + Args: + X (FDataGrid): + The data to smooth. + y (FDataGrid): + The target data. Typically the same as ``X``. + Returns: + self (object) + + """ from .validation import LinearSmootherGeneralizedCVScorer return LinearSmootherGeneralizedCVScorer()(self, X, y) diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 540eb93d1..9d30d998d 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -125,7 +125,8 @@ class SmoothingParameterSearch(GridSearchCV): param_values (iterable): iterable containing the values to test for *smoothing_parameter*. scoring (scoring method): scoring method used to measure the - performance of the smoothing. + performance of the smoothing. If ``None`` (the default) the + ``score`` method of the estimator is used. n_jobs (int or None, optional (default=None)): Number of jobs to run in parallel. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` @@ -229,6 +230,7 @@ class SmoothingParameterSearch(GridSearchCV): Different output points can also be used. In that case the value used as a target is still the smoothed value at the input points: + >>> output_points = np.linspace(-2, 2, 9) >>> grid = SmoothingParameterSearch( ... kernel_smoothers.KNeighborsSmoother( From d0b7491d991695f0c48c19716f95acc48b23b609 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 25 Jun 2019 15:52:41 +0200 Subject: [PATCH 119/222] Correct typo in `NadarayaWatsonSmoother` --- skfda/preprocessing/smoothing/kernel_smoothers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 3552bd7eb..b4e51305e 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -148,7 +148,7 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): .. math:: \hat{H}_{i,j} = \frac{K\left(\frac{x_i-x_j}{h}\right)}{\sum_{k=1}^{ n}K\left( - \frac{x_1-x_k}{h}\right)} + \frac{x_i-x_k}{h}\right)} where :math:`K(\cdot)` is a kernel function and :math:`h` the kernel window width or smoothing parameter. From a99410de3d584f78cb36d801b8c03d84bd25cd49 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 26 Jun 2019 12:02:09 +0200 Subject: [PATCH 120/222] Linear and Polynomial covariance functions --- skfda/misc/covariances.py | 67 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/skfda/misc/covariances.py b/skfda/misc/covariances.py index d7de49f65..9259e2741 100644 --- a/skfda/misc/covariances.py +++ b/skfda/misc/covariances.py @@ -39,7 +39,7 @@ def _execute_covariance(covariance, x, y): class Brownian(): """Brownian covariance""" - def __init__(self, *, variance: float=1., origin: float=0.): + def __init__(self, *, variance: float = 1., origin: float = 0.): self.variance = variance self.origin = origin @@ -50,6 +50,10 @@ def __call__(self, x, y): return self.variance * (np.abs(x) + np.abs(y.T) - np.abs(x - y.T)) / 2 + def __repr__(self): + return (f"{self.__module__}.{type(self).__qualname__}(" + f"variance={self.variance}, origin={self.origin})") + def _repr_latex_(self): return (r"\[K(x, y) = \sigma^2 \frac{|x - \mathcal{O}| + " r"|y - \mathcal{O}| - |x-y|}{2}\]" @@ -58,3 +62,64 @@ def _repr_latex_(self): fr"\qquad\sigma^2 &= {self.variance} \\" fr"\mathcal{{O}} &= {self.origin} \\" r"\end{align*}") + + +class Linear(): + """Linear covariance""" + + def __init__(self, *, variance: float = 1., intercept: float = 0.): + self.variance = variance + self.intercept = intercept + + def __call__(self, x, y): + """Brownian covariance function""" + x = np.asarray(x) + y = np.asarray(y) + + return self.variance * (x @ y.T + self.intercept) + + def __repr__(self): + return (f"{self.__module__}.{type(self).__qualname__}(" + f"variance={self.variance}, intercept={self.intercept})") + + def _repr_latex_(self): + return (r"\[K(x, y) = \sigma^2 (x^T y + c)\]" + "where:" + r"\begin{align*}" + fr"\qquad\sigma^2 &= {self.variance} \\" + fr"c &= {self.intercept} \\" + r"\end{align*}") + + +class Polynomial(): + """Polynomial covariance""" + + def __init__(self, *, variance: float = 1., intercept: float = 0., + slope: float = 1., degree: float = 2.): + self.variance = variance + self.intercept = intercept + self.slope = slope + self.degree = degree + + def __call__(self, x, y): + """Brownian covariance function""" + x = np.asarray(x) + y = np.asarray(y) + + return self.variance * (self.slope * x @ y.T + + self.intercept)**self.degree + + def __repr__(self): + return (f"{self.__module__}.{type(self).__qualname__}(" + f"variance={self.variance}, intercept={self.intercept}, " + f"slope={self.slope}, degree={self.degree})") + + def _repr_latex_(self): + return (r"\[K(x, y) = \sigma^2 (\alpha x^T y + c)^d\]" + "where:" + r"\begin{align*}" + fr"\qquad\sigma^2 &= {self.variance} \\" + fr"\alpha &= {self.slope} \\" + fr"c &= {self.intercept} \\" + fr"d &= {self.degree} \\" + r"\end{align*}") From 3707b71aa098a4e4835be865e1ae3ef077d51508 Mon Sep 17 00:00:00 2001 From: pablomm Date: Wed, 26 Jun 2019 12:57:07 +0200 Subject: [PATCH 121/222] Removed @abstractproperty and typo in doc --- skfda/representation/_functional_data.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 8d544c802..28e5b6f46 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -66,7 +66,8 @@ def axes_labels(self, labels): - @abstractproperty + @property + @abstractmethod def nsamples(self): """Return the number of samples. @@ -76,7 +77,8 @@ def nsamples(self): """ pass - @abstractproperty + @property + @abstractmethod def ndim_domain(self): """Return number of dimensions of the domain. @@ -86,7 +88,8 @@ def ndim_domain(self): """ pass - @abstractproperty + @property + @abstractmethod def ndim_image(self): """Return number of dimensions of the image. @@ -106,7 +109,8 @@ def ndim_codomain(self): """ return self.ndim_image - @abstractproperty + @property + @abstractmethod def coordinates(self): r"""Return a component of the FDataGrid. @@ -145,7 +149,8 @@ def extrapolator_evaluator(self): return self._extrapolator_evaluator - @abstractproperty + @property + @abstractmethod def domain_range(self): """Return the domain range of the object @@ -701,7 +706,7 @@ def _join_labels_coordinates(self, *others): functional objects. Args: - others (:obj:`FData`) Obects to be concatenates. + others (:obj:`FData`) Objects to be concatenated. Returns: (list): labels of the object From 58cedb0201337668dfcb3832c08f31f7ba4e31a1 Mon Sep 17 00:00:00 2001 From: Pablo Manso Date: Wed, 26 Jun 2019 18:39:37 +0200 Subject: [PATCH 122/222] Finishing flake 8 fixes --- skfda/_utils.py | 2 +- .../preprocessing/smoothing/kernel_smoothers.py | 16 ++++++++-------- skfda/representation/_functional_data.py | 12 +++++------- skfda/representation/basis.py | 5 ++--- skfda/representation/grid.py | 8 ++------ 5 files changed, 18 insertions(+), 25 deletions(-) diff --git a/skfda/_utils.py b/skfda/_utils.py index 1c6c163d6..ab7d4b928 100644 --- a/skfda/_utils.py +++ b/skfda/_utils.py @@ -111,7 +111,7 @@ class cls(f): for key, value in alias_assignments.items(): def getter(self): - return getattr(self, key) + return getattr(self, key) def setter(self, new_value): return setattr(self, key, new_value) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index b4e51305e..445033d24 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -70,7 +70,7 @@ def _hat_matrix_function_not_normalized(self, *, delta_x, def _more_tags(self): return { 'X_types': [] - } + } def fit(self, X: FDataGrid, y=None): """Compute the hat matrix for the desired output points. @@ -91,13 +91,13 @@ def fit(self, X: FDataGrid, y=None): else self.input_points_) self.hat_matrix_ = self._hat_matrix_function( - input_points=self.input_points_, - output_points=self.output_points_, - smoothing_parameter=self.smoothing_parameter, - kernel=self.kernel, - weights=self.weights, - _cv=self._cv - ) + input_points=self.input_points_, + output_points=self.output_points_, + smoothing_parameter=self.smoothing_parameter, + kernel=self.kernel, + weights=self.weights, + _cv=self._cv + ) return self diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 998dd5da2..79568c5b9 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -57,15 +57,13 @@ def axes_labels(self, labels): labels = np.asarray(labels) if len(labels) > (self.ndim_domain + self.ndim_image): raise ValueError("There must be a label for each of the " - "dimensions of the domain and the image.") + "dimensions of the domain and the image.") if len(labels) < (self.ndim_domain + self.ndim_image): diff = (self.ndim_domain + self.ndim_image) - len(labels) - labels = np.concatenate((labels, diff*[None])) + labels = np.concatenate((labels, diff * [None])) self._axes_labels = labels - - @property @abstractmethod def nsamples(self): @@ -698,10 +696,10 @@ def _get_labels_coordinates(self, key): else: labels = self.axes_labels[:self.ndim_domain].tolist() - image_label = np.atleast_1d(self.axes_labels[self.ndim_domain:][key]) + image_label = np.atleast_1d( + self.axes_labels[self.ndim_domain:][key]) labels.extend(image_label.tolist()) - return labels def _join_labels_coordinates(self, *others): @@ -770,7 +768,7 @@ def set_labels(self, fig=None, ax=None, patches=None): ax[i].set_xlabel(self.axes_labels[0]) if self.axes_labels[1] is not None: ax[i].set_ylabel(self.axes_labels[1]) - if self.axes_labels[i+2] is not None: + if self.axes_labels[i + 2] is not None: ax[i].set_zlabel(self.axes_labels[i + 2]) else: for i in range(self.ndim_image): diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index aa5b17e8c..77d065f3b 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -2378,7 +2378,8 @@ def concatenate(self, *others, as_coordinates=False): representation. """ - # TODO: Change to support multivariate functions in basis representation + # TODO: Change to support multivariate functions + # in basis representation if as_coordinates: return NotImplemented @@ -2388,10 +2389,8 @@ def concatenate(self, *others, as_coordinates=False): data = [self.coefficients] + [other.coefficients for other in others] - return self.copy(coefficients=np.concatenate(data, axis=0)) - def compose(self, fd, *, eval_points=None, **kwargs): """Composition of functions. diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 81e641223..eee71314e 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -693,7 +693,6 @@ def __rtruediv__(self, other): return self.copy(data_matrix=data_matrix / self.data_matrix) - def concatenate(self, *others, as_coordinates=False): """Join samples from a similar FDataGrid object. @@ -741,25 +740,22 @@ def concatenate(self, *others, as_coordinates=False): raise ValueError("All the FDataGrids must be sampled in the same " "sample points.") - elif any([self.nsamples != other.nsamples for other in others]): raise ValueError(f"All the FDataGrids must contain the same " f"number of samples {self.nsamples} to " f"concatenate as a new coordinate.") - data = [self.data_matrix] + [other.data_matrix for other in others] - if as_coordinates: return self.copy(data_matrix=np.concatenate(data, axis=-1), - axes_labels=self._join_labels_coordinates(*others)) + axes_labels=( + self._join_labels_coordinates(*others))) else: return self.copy(data_matrix=np.concatenate(data, axis=0)) - def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): """Scatter plot of the FDatGrid object. From cb7265cbb3c868bb6c1c13e0381ef5f5657344c0 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sun, 23 Jun 2019 20:31:43 +0200 Subject: [PATCH 123/222] Some tests waring fixes --- tests/test_elastic.py | 2 -- tests/test_registration.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/tests/test_elastic.py b/tests/test_elastic.py index c90089e42..5552aaf44 100644 --- a/tests/test_elastic.py +++ b/tests/test_elastic.py @@ -2,8 +2,6 @@ import numpy as np -import matplotlib.pyplot as plt - from skfda import FDataGrid from skfda.datasets import make_multimodal_samples from skfda.misc.metrics import (fisher_rao_distance, amplitude_distance, diff --git a/tests/test_registration.py b/tests/test_registration.py index e8f6d5460..f23e86690 100644 --- a/tests/test_registration.py +++ b/tests/test_registration.py @@ -2,8 +2,6 @@ import numpy as np -import matplotlib.pyplot as plt - from skfda import FDataGrid from skfda.representation.interpolation import SplineInterpolator from skfda.representation.basis import Fourier From c10bec5c33f9d474c63a4c146a93f8411fc611ca Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Wed, 26 Jun 2019 21:27:28 +0200 Subject: [PATCH 124/222] Test fix --- tests/test_magnitude_shape.py | 99 ++++++++++++++++++----------------- 1 file changed, 50 insertions(+), 49 deletions(-) diff --git a/tests/test_magnitude_shape.py b/tests/test_magnitude_shape.py index ac9017a58..6dee94c37 100644 --- a/tests/test_magnitude_shape.py +++ b/tests/test_magnitude_shape.py @@ -43,55 +43,56 @@ def test_directional_outlyingness(self): np.testing.assert_allclose(variation_dir_outl, np.array([0., 0.01505136, 0.09375])) - def test_magnitude_shape_plot(self): - fd = fetch_weather()["data"] - fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], - sample_points=fd.sample_points, - dataset_label=fd.dataset_label, - axes_labels=fd.axes_labels[0:2]) - msplot = MagnitudeShapePlot(fd_temperatures, random_state=0) - np.testing.assert_allclose(msplot.points, - np.array([[0.28216472, 3.15069249], - [1.43406267, 0.77729052], - [0.96089808, 2.7302293], - [2.1469911, 7.06601804], - [0.89081951, 0.71098079], - [1.22591999, 0.2363983], - [-2.65530111, 0.9666511], - [0.47819535, 0.83989187], - [-0.11256072, 0.89035836], - [0.99627103, 0.3255725], - [0.77889317, 0.32451932], - [3.47490723, 12.5630275], - [3.14828582, 13.80605804], - [3.51793514, 10.46943904], - [3.94435195, 15.24142224], - [0., 0.], - [0.74574282, 6.68207165], - [-0.82501844, 0.82694929], - [-3.4617439, 1.10389229], - [0.44523944, 1.61262494], - [-0.52255157, 1.00486028], - [-1.67260144, 0.74626351], - [-0.10133788, 0.96326946], - [0.36576472, 0.93071675], - [7.57827303, 40.70985885], - [7.51140842, 36.65641988], - [7.13000185, 45.56574331], - [0.28166597, 1.70861091], - [1.55486533, 8.75149947], - [-1.43363018, 0.36935927], - [-2.79138743, 4.80007762], - [-2.39987853, 1.54992208], - [-5.87118328, 5.34300766], - [-5.42854833, 5.1694065], - [-16.34459211, 0.9397118]])) - np.testing.assert_array_almost_equal(msplot.outliers, - np.array( - [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, - 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, - 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, - 0, 0, 0, 0, 1])) + # TODO review the bug in the dataset download + #def test_magnitude_shape_plot(self): + # fd = fetch_weather()["data"] + # fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], + # sample_points=fd.sample_points, + # dataset_label=fd.dataset_label, + # axes_labels=fd.axes_labels[0:2]) + # msplot = MagnitudeShapePlot(fd_temperatures, random_state=0) + # np.testing.assert_allclose(msplot.points, + # np.array([[0.28216472, 3.15069249], + # [1.43406267, 0.77729052], + # [0.96089808, 2.7302293], + # [2.1469911, 7.06601804], + # [0.89081951, 0.71098079], + # [1.22591999, 0.2363983], + # [-2.65530111, 0.9666511], + # [0.47819535, 0.83989187], + # [-0.11256072, 0.89035836], + # [0.99627103, 0.3255725], + # [0.77889317, 0.32451932], + # [3.47490723, 12.5630275], + # [3.14828582, 13.80605804], + # [3.51793514, 10.46943904], + # [3.94435195, 15.24142224], + # [0., 0.], + # [0.74574282, 6.68207165], + # [-0.82501844, 0.82694929], + # [-3.4617439, 1.10389229], + # [0.44523944, 1.61262494], + # [-0.52255157, 1.00486028], + # [-1.67260144, 0.74626351], + # [-0.10133788, 0.96326946], + # [0.36576472, 0.93071675], + # [7.57827303, 40.70985885], + # [7.51140842, 36.65641988], + # [7.13000185, 45.56574331], + # [0.28166597, 1.70861091], + # [1.55486533, 8.75149947], + # [-1.43363018, 0.36935927], + # [-2.79138743, 4.80007762], + # [-2.39987853, 1.54992208], + # [-5.87118328, 5.34300766], + # [-5.42854833, 5.1694065], + # [-16.34459211, 0.9397118]])) + # np.testing.assert_array_almost_equal(msplot.outliers, + # np.array( + # [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + # 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, + # 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, + # 0, 0, 0, 0, 1])) if __name__ == '__main__': From 3cac9a04ea9122db22f87e488763c4520ffaaa28 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 27 Jun 2019 19:23:03 +0200 Subject: [PATCH 125/222] Add Gaussian covariance and ABC class with rich representation --- skfda/_utils.py | 12 +++ skfda/misc/covariances.py | 184 ++++++++++++++++++++++++++++---------- 2 files changed, 147 insertions(+), 49 deletions(-) diff --git a/skfda/_utils.py b/skfda/_utils.py index 1c6c163d6..7137c8ee0 100644 --- a/skfda/_utils.py +++ b/skfda/_utils.py @@ -3,6 +3,8 @@ import numpy as np import functools import types +import matplotlib.backends.backend_svg +import io def _list_of_arrays(original_array): @@ -136,3 +138,13 @@ def _check_estimator(estimator): instance = estimator() check_get_params_invariance(name, instance) check_set_params(name, instance) + + +def _figure_to_svg(figure): + old_canvas = figure.canvas + matplotlib.backends.backend_svg.FigureCanvas(figure) + output = io.BytesIO() + figure.savefig(output, format='svg') + figure.set_canvas(old_canvas) + data = output.getvalue() + return data.decode('utf-8') diff --git a/skfda/misc/covariances.py b/skfda/misc/covariances.py index 9259e2741..3106451f9 100644 --- a/skfda/misc/covariances.py +++ b/skfda/misc/covariances.py @@ -1,6 +1,14 @@ import numbers import numpy as np +import abc +import matplotlib + +from .._utils import _figure_to_svg + + +def _squared_norms(x, y): + return ((x[np.newaxis, :, :] - y[:, np.newaxis, :]) ** 2).sum(2) def _transform_to_2d(t): @@ -36,63 +44,135 @@ def _execute_covariance(covariance, x, y): return result -class Brownian(): - """Brownian covariance""" +class Covariance(abc.ABC): + """Abstract class for covariance functions""" + + @abc.abstractmethod + def __call__(self, x, y): + pass + + def heatmap(self): + x = np.linspace(-1, 1, 1000) + + cov_matrix = self(x, x) + + fig = matplotlib.figure.Figure() + ax = fig.add_subplot(1, 1, 1) + ax.imshow(cov_matrix, extent=[-1, 1, 1, -1]) + ax.set_title("Covariance function in [-1, 1]") + + return fig, ax + + def _sample_trajectories_plot(self): + from ..datasets import make_gaussian_process + + fd = make_gaussian_process(cov=self) + fig, ax = fd.plot() + ax[0].set_title("Sample trajectories") + return fig, ax + + def __repr__(self): + + params = ', '.join(f'{n}={getattr(self, n)}' + for n, _ in self._parameters) + + return (f"{self.__module__}.{type(self).__qualname__}(" + f"{params}" + f")") + + def _latex_content(self): + params = ''.join(fr'{l} &= {getattr(self, n)} \\' + for n, l in self._parameters) + + return (fr"{self._latex_formula} \\" + r"\text{where:}" + r"\begin{aligned}" + fr"\qquad{params}" + r"\end{aligned}") + + def _repr_latex_(self): + return fr"\(\displaystyle {self._latex_content()}\)" + + def _repr_html_(self): + fig, _ = self.heatmap() + heatmap = _figure_to_svg(fig) + + fig, _ = self._sample_trajectories_plot() + sample_trajectories = _figure_to_svg(fig) + + row_style = 'style="display: flex; display:table-row"' + + def column_style(percent): + return (f'style="flex: {percent}%; display: table-cell; ' + f'vertical-align: middle"') + + html = f""" +

+
+
+ {sample_trajectories} +
+
+ {heatmap} +
+
+ """ + + return html + + +class Brownian(Covariance): + """Brownian covariance function.""" + + _latex_formula = (r"K(x, y) = \sigma^2 \frac{|x - \mathcal{O}| + " + r"|y - \mathcal{O}| - |x-y|}{2}") + + _parameters = [("variance", r"\sigma^2"), + ("origin", r"\mathcal{O}")] def __init__(self, *, variance: float = 1., origin: float = 0.): self.variance = variance self.origin = origin def __call__(self, x, y): - """Brownian covariance function""" - x = np.asarray(x) - self.origin - y = np.asarray(y) - self.origin + x = _transform_to_2d(x) - self.origin + y = _transform_to_2d(y) - self.origin return self.variance * (np.abs(x) + np.abs(y.T) - np.abs(x - y.T)) / 2 - def __repr__(self): - return (f"{self.__module__}.{type(self).__qualname__}(" - f"variance={self.variance}, origin={self.origin})") - def _repr_latex_(self): - return (r"\[K(x, y) = \sigma^2 \frac{|x - \mathcal{O}| + " - r"|y - \mathcal{O}| - |x-y|}{2}\]" - "where:" - r"\begin{align*}" - fr"\qquad\sigma^2 &= {self.variance} \\" - fr"\mathcal{{O}} &= {self.origin} \\" - r"\end{align*}") +class Linear(Covariance): + """Linear covariance function.""" + _latex_formula = r"K(x, y) = \sigma^2 (x^T y + c)" -class Linear(): - """Linear covariance""" + _parameters = [("variance", r"\sigma^2"), + ("intercept", r"c")] def __init__(self, *, variance: float = 1., intercept: float = 0.): self.variance = variance self.intercept = intercept def __call__(self, x, y): - """Brownian covariance function""" - x = np.asarray(x) - y = np.asarray(y) + x = _transform_to_2d(x) + y = _transform_to_2d(y) return self.variance * (x @ y.T + self.intercept) - def __repr__(self): - return (f"{self.__module__}.{type(self).__qualname__}(" - f"variance={self.variance}, intercept={self.intercept})") - def _repr_latex_(self): - return (r"\[K(x, y) = \sigma^2 (x^T y + c)\]" - "where:" - r"\begin{align*}" - fr"\qquad\sigma^2 &= {self.variance} \\" - fr"c &= {self.intercept} \\" - r"\end{align*}") +class Polynomial(Covariance): + """Polynomial covariance function.""" + _latex_formula = r"K(x, y) = \sigma^2 (\alpha x^T y + c)^d" -class Polynomial(): - """Polynomial covariance""" + _parameters = [("variance", r"\sigma^2"), + ("intercept", r"c"), + ("slope", r"\alpha"), + ("degree", r"d")] def __init__(self, *, variance: float = 1., intercept: float = 0., slope: float = 1., degree: float = 2.): @@ -102,24 +182,30 @@ def __init__(self, *, variance: float = 1., intercept: float = 0., self.degree = degree def __call__(self, x, y): - """Brownian covariance function""" - x = np.asarray(x) - y = np.asarray(y) + x = _transform_to_2d(x) + y = _transform_to_2d(y) return self.variance * (self.slope * x @ y.T + self.intercept)**self.degree - def __repr__(self): - return (f"{self.__module__}.{type(self).__qualname__}(" - f"variance={self.variance}, intercept={self.intercept}, " - f"slope={self.slope}, degree={self.degree})") - def _repr_latex_(self): - return (r"\[K(x, y) = \sigma^2 (\alpha x^T y + c)^d\]" - "where:" - r"\begin{align*}" - fr"\qquad\sigma^2 &= {self.variance} \\" - fr"\alpha &= {self.slope} \\" - fr"c &= {self.intercept} \\" - fr"d &= {self.degree} \\" - r"\end{align*}") +class Gaussian(Covariance): + """Gaussian covariance function.""" + + _latex_formula = (r"K(x, y) = \sigma^2 \exp\left(\frac{||x - y||^2}{2l^2}" + r"\right)") + + _parameters = [("variance", r"\sigma^2"), + ("length_scale", r"l")] + + def __init__(self, *, variance: float = 1., length_scale: float = 1.): + self.variance = variance + self.length_scale = length_scale + + def __call__(self, x, y): + x = _transform_to_2d(x) + y = _transform_to_2d(y) + + x_y = _squared_norms(x, y) + + return self.variance * np.exp(-x_y / (2 * self.length_scale**2)) From 910111e1cea05e1fa12047c0f1fd1402cd65227b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 27 Jun 2019 19:49:30 +0200 Subject: [PATCH 126/222] Add the Exponential covariance function --- skfda/misc/covariances.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/skfda/misc/covariances.py b/skfda/misc/covariances.py index 3106451f9..31f6256bc 100644 --- a/skfda/misc/covariances.py +++ b/skfda/misc/covariances.py @@ -66,7 +66,7 @@ def heatmap(self): def _sample_trajectories_plot(self): from ..datasets import make_gaussian_process - fd = make_gaussian_process(cov=self) + fd = make_gaussian_process(start=-1, cov=self) fig, ax = fd.plot() ax[0].set_title("Sample trajectories") return fig, ax @@ -209,3 +209,25 @@ def __call__(self, x, y): x_y = _squared_norms(x, y) return self.variance * np.exp(-x_y / (2 * self.length_scale**2)) + + +class Exponential(Covariance): + """Exponential covariance function.""" + + _latex_formula = (r"K(x, y) = \sigma^2 \exp\left(\frac{||x - y||}{l}" + r"\right)") + + _parameters = [("variance", r"\sigma^2"), + ("length_scale", r"l")] + + def __init__(self, *, variance: float = 1., length_scale: float = 1.): + self.variance = variance + self.length_scale = length_scale + + def __call__(self, x, y): + x = _transform_to_2d(x) + y = _transform_to_2d(y) + + x_y = _squared_norms(x, y) + + return self.variance * np.exp(-np.sqrt(x_y) / (self.length_scale)) From 11e79d4bdd3abf79654440c92f990cf62b18882e Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 28 Jun 2019 13:33:47 +0200 Subject: [PATCH 127/222] Add method to convert to Sklearn and tests --- skfda/misc/covariances.py | 49 ++++++++++++++++++++++++------ tests/test_covariances.py | 64 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 tests/test_covariances.py diff --git a/skfda/misc/covariances.py b/skfda/misc/covariances.py index 31f6256bc..814ec7aa8 100644 --- a/skfda/misc/covariances.py +++ b/skfda/misc/covariances.py @@ -1,9 +1,11 @@ +import abc import numbers -import numpy as np -import abc import matplotlib +import numpy as np +import sklearn.gaussian_process.kernels as sklearn_kern + from .._utils import _figure_to_svg @@ -124,6 +126,11 @@ def column_style(percent): return html + def to_sklearn(self): + """Convert it to a sklearn kernel, if there is one""" + raise NotImplementedError(f"{type(self).__name__} covariance not " + f"implemented in scikit-learn") + class Brownian(Covariance): """Brownian covariance function.""" @@ -134,7 +141,7 @@ class Brownian(Covariance): _parameters = [("variance", r"\sigma^2"), ("origin", r"\mathcal{O}")] - def __init__(self, *, variance: float = 1., origin: float = 0.): + def __init__(self, *, variance: float=1., origin: float=0.): self.variance = variance self.origin = origin @@ -153,7 +160,7 @@ class Linear(Covariance): _parameters = [("variance", r"\sigma^2"), ("intercept", r"c")] - def __init__(self, *, variance: float = 1., intercept: float = 0.): + def __init__(self, *, variance: float=1., intercept: float=0.): self.variance = variance self.intercept = intercept @@ -163,6 +170,11 @@ def __call__(self, x, y): return self.variance * (x @ y.T + self.intercept) + def to_sklearn(self): + """Convert it to a sklearn kernel, if there is one""" + return (self.variance * + (sklearn_kern.DotProduct(0) + self.intercept)) + class Polynomial(Covariance): """Polynomial covariance function.""" @@ -174,8 +186,8 @@ class Polynomial(Covariance): ("slope", r"\alpha"), ("degree", r"d")] - def __init__(self, *, variance: float = 1., intercept: float = 0., - slope: float = 1., degree: float = 2.): + def __init__(self, *, variance: float=1., intercept: float=0., + slope: float=1., degree: float=2.): self.variance = variance self.intercept = intercept self.slope = slope @@ -186,7 +198,14 @@ def __call__(self, x, y): y = _transform_to_2d(y) return self.variance * (self.slope * x @ y.T - + self.intercept)**self.degree + + self.intercept) ** self.degree + + def to_sklearn(self): + """Convert it to a sklearn kernel, if there is one""" + return (self.variance * + (self.slope * + sklearn_kern.DotProduct(0) + + self.intercept) + ** self.degree) class Gaussian(Covariance): @@ -198,7 +217,7 @@ class Gaussian(Covariance): _parameters = [("variance", r"\sigma^2"), ("length_scale", r"l")] - def __init__(self, *, variance: float = 1., length_scale: float = 1.): + def __init__(self, *, variance: float=1., length_scale: float=1.): self.variance = variance self.length_scale = length_scale @@ -208,7 +227,12 @@ def __call__(self, x, y): x_y = _squared_norms(x, y) - return self.variance * np.exp(-x_y / (2 * self.length_scale**2)) + return self.variance * np.exp(-x_y / (2 * self.length_scale ** 2)) + + def to_sklearn(self): + """Convert it to a sklearn kernel, if there is one""" + return (self.variance * + sklearn_kern.RBF(length_scale=self.length_scale)) class Exponential(Covariance): @@ -220,7 +244,7 @@ class Exponential(Covariance): _parameters = [("variance", r"\sigma^2"), ("length_scale", r"l")] - def __init__(self, *, variance: float = 1., length_scale: float = 1.): + def __init__(self, *, variance: float=1., length_scale: float=1.): self.variance = variance self.length_scale = length_scale @@ -231,3 +255,8 @@ def __call__(self, x, y): x_y = _squared_norms(x, y) return self.variance * np.exp(-np.sqrt(x_y) / (self.length_scale)) + + def to_sklearn(self): + """Convert it to a sklearn kernel, if there is one""" + return (self.variance * + sklearn_kern.Matern(length_scale=self.length_scale, nu=0.5)) diff --git a/tests/test_covariances.py b/tests/test_covariances.py new file mode 100644 index 000000000..8eccd8066 --- /dev/null +++ b/tests/test_covariances.py @@ -0,0 +1,64 @@ +import unittest + +import numpy as np +import skfda + + +class TestsSklearn(unittest.TestCase): + + def setUp(self): + unittest.TestCase.setUp(self) + + self.x = np.linspace(-1, 1, 1000)[:, np.newaxis] + + def _test_compare_sklearn(self, cov: skfda.misc.covariances.Covariance): + cov_sklearn = cov.to_sklearn() + + cov_matrix = cov(self.x, self.x) + cov_sklearn_matrix = cov_sklearn(self.x, self.x) + + np.testing.assert_array_almost_equal(cov_matrix, cov_sklearn_matrix) + + def test_linear(self): + + for variance in [1, 2]: + for intercept in [0, 1, 2]: + with self.subTest(variance=variance, intercept=intercept): + cov = skfda.misc.covariances.Linear( + variance=variance, intercept=intercept) + self._test_compare_sklearn(cov) + + def test_polynomial(self): + + for variance in [1, 2]: + for intercept in [0, 1, 2]: + for slope in [1, 2]: + for degree in [1, 2, 3]: + with self.subTest(variance=variance, + intercept=intercept, + slope=slope, + degree=degree): + cov = skfda.misc.covariances.Polynomial( + variance=variance, intercept=intercept, + slope=slope, degree=degree) + self._test_compare_sklearn(cov) + + def test_gaussian(self): + + for variance in [1, 2]: + for length_scale in [0.5, 1, 2]: + with self.subTest(variance=variance, + length_scale=length_scale): + cov = skfda.misc.covariances.Gaussian( + variance=variance, length_scale=length_scale) + self._test_compare_sklearn(cov) + + def test_exponential(self): + + for variance in [1, 2]: + for length_scale in [0.5, 1, 2]: + with self.subTest(variance=variance, + length_scale=length_scale): + cov = skfda.misc.covariances.Exponential( + variance=variance, length_scale=length_scale) + self._test_compare_sklearn(cov) From dc0d98ee72ebd1abfbe445aa1a94baacef599181 Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 29 Jun 2019 15:44:36 +0200 Subject: [PATCH 128/222] Uncomment test --- tests/test_magnitude_shape.py | 99 +++++++++++++++++------------------ 1 file changed, 49 insertions(+), 50 deletions(-) diff --git a/tests/test_magnitude_shape.py b/tests/test_magnitude_shape.py index 6dee94c37..ac9017a58 100644 --- a/tests/test_magnitude_shape.py +++ b/tests/test_magnitude_shape.py @@ -43,56 +43,55 @@ def test_directional_outlyingness(self): np.testing.assert_allclose(variation_dir_outl, np.array([0., 0.01505136, 0.09375])) - # TODO review the bug in the dataset download - #def test_magnitude_shape_plot(self): - # fd = fetch_weather()["data"] - # fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], - # sample_points=fd.sample_points, - # dataset_label=fd.dataset_label, - # axes_labels=fd.axes_labels[0:2]) - # msplot = MagnitudeShapePlot(fd_temperatures, random_state=0) - # np.testing.assert_allclose(msplot.points, - # np.array([[0.28216472, 3.15069249], - # [1.43406267, 0.77729052], - # [0.96089808, 2.7302293], - # [2.1469911, 7.06601804], - # [0.89081951, 0.71098079], - # [1.22591999, 0.2363983], - # [-2.65530111, 0.9666511], - # [0.47819535, 0.83989187], - # [-0.11256072, 0.89035836], - # [0.99627103, 0.3255725], - # [0.77889317, 0.32451932], - # [3.47490723, 12.5630275], - # [3.14828582, 13.80605804], - # [3.51793514, 10.46943904], - # [3.94435195, 15.24142224], - # [0., 0.], - # [0.74574282, 6.68207165], - # [-0.82501844, 0.82694929], - # [-3.4617439, 1.10389229], - # [0.44523944, 1.61262494], - # [-0.52255157, 1.00486028], - # [-1.67260144, 0.74626351], - # [-0.10133788, 0.96326946], - # [0.36576472, 0.93071675], - # [7.57827303, 40.70985885], - # [7.51140842, 36.65641988], - # [7.13000185, 45.56574331], - # [0.28166597, 1.70861091], - # [1.55486533, 8.75149947], - # [-1.43363018, 0.36935927], - # [-2.79138743, 4.80007762], - # [-2.39987853, 1.54992208], - # [-5.87118328, 5.34300766], - # [-5.42854833, 5.1694065], - # [-16.34459211, 0.9397118]])) - # np.testing.assert_array_almost_equal(msplot.outliers, - # np.array( - # [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, - # 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, - # 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, - # 0, 0, 0, 0, 1])) + def test_magnitude_shape_plot(self): + fd = fetch_weather()["data"] + fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], + sample_points=fd.sample_points, + dataset_label=fd.dataset_label, + axes_labels=fd.axes_labels[0:2]) + msplot = MagnitudeShapePlot(fd_temperatures, random_state=0) + np.testing.assert_allclose(msplot.points, + np.array([[0.28216472, 3.15069249], + [1.43406267, 0.77729052], + [0.96089808, 2.7302293], + [2.1469911, 7.06601804], + [0.89081951, 0.71098079], + [1.22591999, 0.2363983], + [-2.65530111, 0.9666511], + [0.47819535, 0.83989187], + [-0.11256072, 0.89035836], + [0.99627103, 0.3255725], + [0.77889317, 0.32451932], + [3.47490723, 12.5630275], + [3.14828582, 13.80605804], + [3.51793514, 10.46943904], + [3.94435195, 15.24142224], + [0., 0.], + [0.74574282, 6.68207165], + [-0.82501844, 0.82694929], + [-3.4617439, 1.10389229], + [0.44523944, 1.61262494], + [-0.52255157, 1.00486028], + [-1.67260144, 0.74626351], + [-0.10133788, 0.96326946], + [0.36576472, 0.93071675], + [7.57827303, 40.70985885], + [7.51140842, 36.65641988], + [7.13000185, 45.56574331], + [0.28166597, 1.70861091], + [1.55486533, 8.75149947], + [-1.43363018, 0.36935927], + [-2.79138743, 4.80007762], + [-2.39987853, 1.54992208], + [-5.87118328, 5.34300766], + [-5.42854833, 5.1694065], + [-16.34459211, 0.9397118]])) + np.testing.assert_array_almost_equal(msplot.outliers, + np.array( + [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, + 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, + 0, 0, 0, 0, 1])) if __name__ == '__main__': From f4d505783e4fe4a7ada442e7163263aa9e80bd2e Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 29 Jun 2019 16:31:32 +0200 Subject: [PATCH 129/222] Fix tests --- skfda/ml/regression/linear_model.py | 19 +++++++++---------- tests/test_regression.py | 10 +++++----- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 192a598e5..7de5355f2 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -6,8 +6,7 @@ class LinearScalarRegression(BaseEstimator, RegressorMixin): - def __init__(self, beta_basis, weights=None): - self.beta_ = None + def __init__(self, beta_basis): self.beta_basis = beta_basis def fit(self, X, y=None, sample_weight=None): @@ -50,7 +49,7 @@ def predict(self, X): return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in range(len(self.beta))) for j in range(X[0].nsamples)] - def _argcheck(self, y, x, sample_weight): + def _argcheck(self, y, x, weights = None): """Do some checks to types and shapes""" if all(not isinstance(i, FData) for i in x): raise ValueError("All the dependent variable are scalar.") @@ -60,7 +59,7 @@ def _argcheck(self, y, x, sample_weight): ylen = len(y) xlen = len(x) - blen = len(self.beta) + blen = len(self.beta_basis) domain_range = ([i for i in x if isinstance(i, FData)][0] .domain_range) @@ -77,17 +76,17 @@ def _argcheck(self, y, x, sample_weight): raise ValueError("The number of samples on independent and " "dependent variables should be the same") - if any(not isinstance(b, Basis) for b in self.beta): + if any(not isinstance(b, Basis) for b in self.beta_basis): raise ValueError("Betas should be a list of Basis.") - if self.weights is None: - self.weights = [1 for _ in range(ylen)] + if weights is None: + weights = [1 for _ in range(ylen)] - if len(self.weights) != ylen: + if len(weights) != ylen: raise ValueError("The number of weights should be equal to the " "independent samples.") - if np.any(np.array(self.weights) < 0): + if np.any(np.array(weights) < 0): raise ValueError("The weights should be non negative values") - return y, x, sample_weight + return y, x, weights diff --git a/tests/test_regression.py b/tests/test_regression.py index c5fa78c43..5bd093a1d 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -24,7 +24,7 @@ def test_regression_fit(self): scalar = LinearScalarRegression([beta_basis]) scalar.fit([x_fd], y) - np.testing.assert_array_almost_equal(scalar.beta[0].coefficients, + np.testing.assert_array_almost_equal(scalar.beta_[0].coefficients, beta_fd.coefficients) def test_regression_predict_single_explanatory(self): @@ -144,8 +144,8 @@ def test_error_weights_lenght(self): weights = [1 for _ in range(8)] beta = Monomial(nbasis=7) - scalar = LinearScalarRegression([beta], weights) - np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + scalar = LinearScalarRegression([beta]) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y, weights) def test_error_weights_negative(self): """ Test that none of the weights are negative. """ @@ -155,8 +155,8 @@ def test_error_weights_negative(self): weights = [-1 for _ in range(7)] beta = Monomial(nbasis=7) - scalar = LinearScalarRegression([beta], weights) - np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) + scalar = LinearScalarRegression([beta]) + np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y, weights) if __name__ == '__main__': From bcd31c5dc160486161587ae845a5234531db1d0e Mon Sep 17 00:00:00 2001 From: Pablo Manso <92manso@gmail.com> Date: Sat, 29 Jun 2019 16:44:51 +0200 Subject: [PATCH 130/222] Fitting check --- skfda/ml/regression/linear_model.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 7de5355f2..1698e3710 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -3,6 +3,8 @@ import numpy as np +from sklearn.utils.validation import check_is_fitted + class LinearScalarRegression(BaseEstimator, RegressorMixin): @@ -46,6 +48,7 @@ def fit(self, X, y=None, sample_weight=None): return self def predict(self, X): + check_is_fitted(self, "beta_") return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in range(len(self.beta))) for j in range(X[0].nsamples)] From 2721789501339b222852710e873061e85c75bc37 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 1 Jul 2019 12:47:46 +0200 Subject: [PATCH 131/222] Module with constants, closes #11 --- skfda/_utils/__init__.py | 4 +++ skfda/{ => _utils}/_utils.py | 3 -- skfda/_utils/constants.py | 28 +++++++++++++++++++ .../registration/_shift_registration.py | 5 +++- skfda/representation/_functional_data.py | 6 ++-- skfda/representation/basis.py | 15 +++++----- skfda/representation/grid.py | 5 ++-- 7 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 skfda/_utils/__init__.py rename skfda/{ => _utils}/_utils.py (99%) create mode 100644 skfda/_utils/constants.py diff --git a/skfda/_utils/__init__.py b/skfda/_utils/__init__.py new file mode 100644 index 000000000..6d7d7e221 --- /dev/null +++ b/skfda/_utils/__init__.py @@ -0,0 +1,4 @@ +from . import constants + +from ._utils import (_list_of_arrays, _coordinate_list, + _check_estimator, parameter_aliases) diff --git a/skfda/_utils.py b/skfda/_utils/_utils.py similarity index 99% rename from skfda/_utils.py rename to skfda/_utils/_utils.py index ab7d4b928..e31710ba4 100644 --- a/skfda/_utils.py +++ b/skfda/_utils/_utils.py @@ -1,8 +1,6 @@ """Module with generic methods""" import numpy as np -import functools -import types def _list_of_arrays(original_array): @@ -68,7 +66,6 @@ def _coordinate_list(axes): """ return np.vstack(list(map(np.ravel, np.meshgrid(*axes, indexing='ij')))).T - def parameter_aliases(**alias_assignments): """Allows using aliases for parameters""" def decorator(f): diff --git a/skfda/_utils/constants.py b/skfda/_utils/constants.py new file mode 100644 index 000000000..020f519de --- /dev/null +++ b/skfda/_utils/constants.py @@ -0,0 +1,28 @@ +""" +This module contains the definition of the constants used in the package. +The following constants are defined: +.. data:: BASIS_MIN_FACTOR + Constant used in the discretization of a basis object, by default de + number of points used are the maximun between BASIS_MIN_FACTOR * nbasis +1 + and N_POINTS_FINE_MESH. +.. data:: N_POINTS_FINE_MESH + Constant used in the discretization of a basis object, by default de + number of points used are the maximun between BASIS_MIN_FACTOR * nbasis +1 + and N_POINTS_FINE_MESH. +.. data:: N_POINTS_COARSE_MESH + Constant used in the default discretization of a basis in some methods. +.. data:: N_POINTS_UNIDIMENSIONAL_PLOT_MESH + Number of points used in the evaluation of a function to be plotted. +.. data:: N_POINTS_SURFACE_PLOT_AX + Number of points per axis used in the evaluation of a surface to be plotted. +""" + +BASIS_MIN_FACTOR = 10 + +N_POINTS_COARSE_MESH = 201 + +N_POINTS_FINE_MESH = 501 + +N_POINTS_SURFACE_PLOT_AX = 30 + +N_POINTS_UNIDIMENSIONAL_PLOT_MESH = 501 diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 39a030d78..43e138fb5 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -7,6 +7,8 @@ import numpy as np import scipy.integrate +from .._utils import constants + __author__ = "Pablo Marcos Manchón" __email__ = "pablo.marcosm@estudiante.uam.es" @@ -118,7 +120,8 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, eval_points = fd.sample_points[0] nfine = len(eval_points) except AttributeError: - nfine = max(fd.nbasis * 10 + 1, 201) + nfine = max(fd.nbasis * constants.BASIS_MIN_FACTOR + 1, + constants.N_POINTS_COARSE_MESH) eval_points = np.linspace(*domain_range, nfine) else: diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 79568c5b9..f46c68c71 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -16,7 +16,7 @@ import pandas.api.extensions from skfda.representation.extrapolation import _parse_extrapolation -from .._utils import _coordinate_list, _list_of_arrays +from .._utils import _coordinate_list, _list_of_arrays, constants class FData(ABC, pandas.api.extensions.ExtensionArray): @@ -980,7 +980,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, if self.ndim_domain == 1: if npoints is None: - npoints = 501 + npoints = constants.N_POINTS_UNIDIMENSIONAL_PLOT_MESH # Evaluates the object in a linspace eval_points = np.linspace(*domain_range[0], npoints) @@ -997,7 +997,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, # Selects the number of points if npoints is None: - npoints = (30, 30) + npoints = 2*(constants.N_POINTS_SURFACE_PLOT_AX,) elif np.isscalar(npoints): npoints = (npoints, npoints) elif len(npoints) != 2: diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 77d065f3b..c66a17a5f 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -18,15 +18,12 @@ from . import grid from . import FData -from .._utils import _list_of_arrays +from .._utils import _list_of_arrays, constants import pandas.api.extensions __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" -MIN_EVAL_SAMPLES = 201 - - # aux functions def _polypow(p, n=2): if n > 2: @@ -1945,7 +1942,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, domain_range = self.domain_range[0] if eval_points is None: # Grid to discretize the function - nfine = max(self.nbasis * 10 + 1, 201) + nfine = max(self.nbasis * 10 + 1, constants.N_POINTS_COARSE_MESH) eval_points = np.linspace(*domain_range, nfine) else: eval_points = np.asarray(eval_points) @@ -2127,7 +2124,8 @@ def to_grid(self, eval_points=None): raise NotImplementedError if eval_points is None: - npoints = max(501, 10 * self.nbasis) + npoints = max(constants.N_POINTS_FINE_MESH, + constants.BASIS_MIN_FACTOR * self.nbasis) eval_points = np.linspace(*self.domain_range[0], npoints) return grid.FDataGrid(self.evaluate(eval_points, keepdims=False), @@ -2206,8 +2204,9 @@ def times(self, other): raise ValueError("The functions domains are different.") basisobj = self.basis.basis_of_product(other.basis) - neval = max(10 * max(self.nbasis, other.nbasis) + 1, - MIN_EVAL_SAMPLES) + neval = max(constants.BASIS_MIN_FACTOR * + max(self.nbasis, other.nbasis) + 1, + constants.N_POINTS_COARSE_MESH) (left, right) = self.domain_range[0] evalarg = np.linspace(left, right, neval) diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index eee71314e..14faa318e 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -17,7 +17,7 @@ from . import basis as fdbasis from .interpolation import SplineInterpolator from . import FData -from .._utils import _list_of_arrays +from .._utils import _list_of_arrays, constants __author__ = "Miguel Carbajo Berrocal" @@ -1020,7 +1020,8 @@ def compose(self, fd, *, eval_points=None): try: eval_points = fd.sample_points[0] except AttributeError: - eval_points = np.linspace(*fd.domain_range[0], 201) + eval_points = np.linspace(*fd.domain_range[0], + constants.N_POINTS_COARSE_MESH) eval_points_transformation = fd(eval_points, keepdims=False) data_matrix = self(eval_points_transformation, From 99904d2077e17de4192b45ad09fa728427b0cf24 Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 1 Jul 2019 12:49:38 +0200 Subject: [PATCH 132/222] import missed --- skfda/_utils/_utils.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/skfda/_utils/_utils.py b/skfda/_utils/_utils.py index e31710ba4..ab7d4b928 100644 --- a/skfda/_utils/_utils.py +++ b/skfda/_utils/_utils.py @@ -1,6 +1,8 @@ """Module with generic methods""" import numpy as np +import functools +import types def _list_of_arrays(original_array): @@ -66,6 +68,7 @@ def _coordinate_list(axes): """ return np.vstack(list(map(np.ravel, np.meshgrid(*axes, indexing='ij')))).T + def parameter_aliases(**alias_assignments): """Allows using aliases for parameters""" def decorator(f): From 597d9c9f402ff187cb539111d1c3491c8a8f5b1b Mon Sep 17 00:00:00 2001 From: pablomm Date: Mon, 1 Jul 2019 13:00:13 +0200 Subject: [PATCH 133/222] Error in import path --- skfda/preprocessing/registration/_shift_registration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 43e138fb5..447f0f249 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -7,7 +7,7 @@ import numpy as np import scipy.integrate -from .._utils import constants +from ..._utils import constants __author__ = "Pablo Marcos Manchón" __email__ = "pablo.marcosm@estudiante.uam.es" From a78d8d493c61e8b93b278c1ce7af93b1e3106a80 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Mon, 1 Jul 2019 15:36:03 +0200 Subject: [PATCH 134/222] Update skfda/_utils/constants.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Carlos Ramos Carreño --- skfda/_utils/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/_utils/constants.py b/skfda/_utils/constants.py index 020f519de..254898aa9 100644 --- a/skfda/_utils/constants.py +++ b/skfda/_utils/constants.py @@ -7,7 +7,7 @@ and N_POINTS_FINE_MESH. .. data:: N_POINTS_FINE_MESH Constant used in the discretization of a basis object, by default de - number of points used are the maximun between BASIS_MIN_FACTOR * nbasis +1 + number of points used are the maximum between BASIS_MIN_FACTOR * nbasis +1 and N_POINTS_FINE_MESH. .. data:: N_POINTS_COARSE_MESH Constant used in the default discretization of a basis in some methods. From b6f70410ac31b9925a8b71fa6728804d5f6b8e06 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Mon, 1 Jul 2019 15:36:12 +0200 Subject: [PATCH 135/222] Update skfda/_utils/constants.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Carlos Ramos Carreño --- skfda/_utils/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/_utils/constants.py b/skfda/_utils/constants.py index 254898aa9..ecd96756c 100644 --- a/skfda/_utils/constants.py +++ b/skfda/_utils/constants.py @@ -3,7 +3,7 @@ The following constants are defined: .. data:: BASIS_MIN_FACTOR Constant used in the discretization of a basis object, by default de - number of points used are the maximun between BASIS_MIN_FACTOR * nbasis +1 + number of points used are the maximum between BASIS_MIN_FACTOR * nbasis +1 and N_POINTS_FINE_MESH. .. data:: N_POINTS_FINE_MESH Constant used in the discretization of a basis object, by default de From 192b5083be15ad58a1654158b976d57d9cc156fc Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 3 Jul 2019 19:51:19 +0200 Subject: [PATCH 136/222] First version of basis smoother. --- skfda/preprocessing/smoothing/__init__.py | 1 + skfda/preprocessing/smoothing/_basis.py | 362 ++++++++++++++++++ skfda/preprocessing/smoothing/_linear.py | 125 ++++++ .../smoothing/kernel_smoothers.py | 112 ++---- skfda/preprocessing/smoothing/validation.py | 13 +- skfda/representation/basis.py | 54 ++- 6 files changed, 565 insertions(+), 102 deletions(-) create mode 100644 skfda/preprocessing/smoothing/_basis.py create mode 100644 skfda/preprocessing/smoothing/_linear.py diff --git a/skfda/preprocessing/smoothing/__init__.py b/skfda/preprocessing/smoothing/__init__.py index 4435d8cd0..3fd39f483 100644 --- a/skfda/preprocessing/smoothing/__init__.py +++ b/skfda/preprocessing/smoothing/__init__.py @@ -1,2 +1,3 @@ from . import kernel_smoothers from . import validation +from ._basis import BasisSmoother diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py new file mode 100644 index 000000000..a005cbd09 --- /dev/null +++ b/skfda/preprocessing/smoothing/_basis.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +"""Basis smoother. + +This module contains the class for the basis smoothing. + +""" +from enum import Enum +from typing import Union + +import scipy.linalg + +import numpy as np + +from ... import FDataBasis +from ... import FDataGrid +from ._linear import _LinearSmoother, _check_r_to_r + + +class _Cholesky(): + """Solve the linear equation using cholesky factorization""" + + def __call__(self, *, basis_values, weight_matrix, data_matrix, + penalty_matrix, **_): + + right_matrix = basis_values.T @ weight_matrix @ data_matrix + left_matrix = basis_values.T @ weight_matrix @ basis_values + + # Adds the roughness penalty to the equation + if penalty_matrix is not None: + left_matrix += penalty_matrix + + coefficients = scipy.linalg.cho_solve(scipy.linalg.cho_factor( + left_matrix, lower=True), right_matrix) + + # The ith column is the coefficients of the ith basis for each + # sample + coefficients = coefficients.T + + return coefficients + + +class _QR(): + """Solve the linear equation using qr factorization""" + + def __call__(self, *, basis_values, weight_matrix, data_matrix, + penalty_matrix, ndegenerated, **_): + + if weight_matrix is not None: + # Decompose W in U'U and calculate UW and Uy + upper = scipy.linalg.cholesky(weight_matrix) + basis_values = upper @ basis_values + data_matrix = upper @ data_matrix + + if penalty_matrix is not None: + w, v = np.linalg.eigh(penalty_matrix) + # Reduction of the penalty matrix taking away 0 or almost + # zeros eigenvalues + + if ndegenerated: + index = ndegenerated - 1 + else: + index = None + w = w[:index:-1] + v = v[:, :index:-1] + + penalty_matrix = v @ np.diag(np.sqrt(w)) + # Augment the basis matrix with the square root of the + # penalty matrix + basis_values = np.concatenate([ + basis_values, + penalty_matrix.T], + axis=0) + # Augment data matrix by n - ndegenerated zeros + data_matrix = np.pad(data_matrix, + ((0, len(v) - ndegenerated), + (0, 0)), + mode='constant') + + # Resolves the equation + # B.T @ B @ C = B.T @ D + # by means of the QR decomposition + + # B = Q @ R + q, r = np.linalg.qr(basis_values) + right_matrix = q.T @ data_matrix + + # R @ C = Q.T @ D + coefficients = np.linalg.solve(r, right_matrix) + # The ith column is the coefficients of the ith basis for each + # sample + coefficients = coefficients.T + + return coefficients + + +class _Matrix(): + """Solve the linear equation using matrix inversion""" + + def fit(self, estimator, X, y=None): + if estimator.return_basis: + estimator._cached_coef_matrix = estimator._coef_matrix( + self.input_points_) + else: + # Force caching the hat matrix + estimator.hat_matrix() + + def fit_transform(self, estimator, X, y=None): + return estimator.fit().transform() + + def __call__(self, *, estimator, **_): + pass + + def transform(self, estimator, X, y=None): + if estimator.return_basis: + coefficients = estimator._cached_coef_matrix @ X.data_matrix + + fdatabasis = FDataBasis( + basis=self.basis, coefficients=coefficients, + keepdims=self.keepdims) + + return fdatabasis + else: + # The matrix is cached + return X.copy(data_matrix=self.hat_matrix() @ X.data_matrix, + sample_points=self.output_points_) + + +class BasisSmoother(_LinearSmoother): + + class SolverMethod(Enum): + cholesky = _Cholesky() + qr = _QR() + matrix = _Matrix() + + def __init__(self, *, + basis, + smoothing_parameter: float = 0, + weights=None, + penalty: Union[int, np.ndarray, + 'LinearDifferentialOperator'] = None, + penalty_matrix=None, + output_points=None, + method='cholesky', + keepdims=False, + return_basis=False): + self.basis = basis + self.smoothing_parameter = smoothing_parameter + self.weights = weights + self.penalty = penalty + self.penalty_matrix = penalty_matrix + self.output_points = output_points + self.method = method + self.keepdims = keepdims + self.return_basis = return_basis + + def _method_function(self): + """ Return the method function""" + method_function = self.method + if not callable(method_function): + method_function = self.SolverMethod[ + method_function.lower()].value + + return method_function + + def _penalty(self): + from ...misc import LinearDifferentialOperator + + """Get the penalty differential operator.""" + if self.penalty is None: + penalty = LinearDifferentialOperator(order=2) + elif isinstance(self.penalty, int): + penalty = LinearDifferentialOperator(order=self.penalty) + elif isinstance(self.penalty, np.ndarray): + penalty = LinearDifferentialOperator(weights=self.penalty) + else: + penalty = self.penalty + + return penalty + + def _penalty_matrix(self): + """Get the final penalty matrix. + + The smoothing parameter is already multiplied by it. + + """ + + if self.penalty_matrix is not None: + penalty_matrix = self.penalty_matrix + else: + penalty = self._penalty() + + if self.smoothing_parameter > 0: + penalty_matrix = self.basis.penalty(penalty.order, + penalty.weights) + else: + penalty_matrix = None + + if penalty_matrix is not None: + penalty_matrix *= self.smoothing_parameter + + return penalty_matrix + + def _coef_matrix(self, input_points): + """Get the matrix that gives the coefficients""" + basis_values_input = self.basis.evaluate(input_points).T + + # If no weight matrix is given all the weights are one + weight_matrix = (self.weights if self.weights is not None + else np.identity(basis_values_input.shape[0])) + + inv = basis_values_input.T @ weight_matrix @ basis_values_input + + penalty_matrix = self._penalty_matrix() + if penalty_matrix is not None: + inv += penalty_matrix + + inv = np.linalg.inv(inv) + + return inv @ basis_values_input + + def _hat_matrix(self, input_points, output_points): + basis_values_output = self.basis.evaluate(output_points).T + + return basis_values_output.T @ self._coef_matrix(input_points) + + def fit(self, X: FDataGrid, y=None): + """Compute the hat matrix for the desired output points. + + Args: + X (FDataGrid): + The data whose points are used to compute the matrix. + y : Ignored + Returns: + self (object) + + """ + _check_r_to_r(X) + + self.input_points_ = X.sample_points[0] + self.output_points_ = (self.output_points + if self.output_points is not None + else self.input_points_) + + method = self._method_function() + method_fit = getattr(method, "fit", None) + if method_fit is not None: + method_fit(estimator=self, X=X, y=y) + + return self + + def fit_transform(self, X: FDataGrid, y=None): + """Compute the hat matrix for the desired output points. + + Args: + X (FDataGrid): + The data whose points are used to compute the matrix. + y : Ignored + Returns: + self (object) + + """ + + _check_r_to_r(X) + + self.input_points_ = X.sample_points[0] + self.output_points_ = (self.output_points + if self.output_points is not None + else self.input_points_) + + penalty_matrix = self._penalty_matrix() + + # n is the samples + # m is the observations + # k is the number of elements of the basis + + # Each sample in a column (m x n) + data_matrix = X.data_matrix[..., 0].T + + # Each basis in a column + basis_values = self.basis.evaluate(self.input_points_).T + + # If no weight matrix is given all the weights are one + weight_matrix = (self.weights if self.weights is not None + else np.identity(basis_values.shape[0])) + + # We need to solve the equation + # (phi' W phi + lambda * R) C = phi' W Y + # where: + # phi is the basis_values + # W is the weight matrix + # lambda the smoothness parameter + # C the coefficient matrix (the unknown) + # Y is the data_matrix + + if(data_matrix.shape[0] > self.basis.nbasis + or self.smoothing_parameter > 0): + + # TODO: The penalty could be None (if the matrix is passed) + ndegenerated = self.basis._ndegenerated(self._penalty().order) + + method = self._method_function() + + # If the method provides the complete transformation use it + method_fit_transform = getattr(method, "fit_transform", None) + if method_fit_transform is not None: + return method_fit_transform(estimator=self, X=X, y=y) + + # Otherwise the method is used to compute the coefficients + coefficients = method(estimator=self, + basis_values=basis_values, + weight_matrix=weight_matrix, + data_matrix=data_matrix, + penalty_matrix=penalty_matrix, + ndegenerated=ndegenerated) + + elif data_matrix.shape[0] == self.basis.nbasis: + # If the number of basis equals the number of points and no + # smoothing is required + coefficients = np.linalg.solve(basis_values, data_matrix) + + else: # data_matrix.shape[0] < basis.nbasis + raise ValueError(f"The number of basis functions " + f"({self.basis.nbasis}) " + f"exceed the number of points to be smoothed " + f"({data_matrix.shape[0]}).") + + fdatabasis = FDataBasis( + basis=self.basis, coefficients=coefficients, + keepdims=self.keepdims) + + if self.return_basis: + return fdatabasis + else: + return fdatabasis(self.output_points_) + + return self + + def transform(self, X: FDataGrid, y=None): + """Apply the smoothing. + + Args: + X (FDataGrid): + The data to smooth. + y : Ignored + Returns: + self (object) + + """ + + assert all(self.input_points_ == X.sample_points[0]) + + method = self._method_function() + + # If the method provides the complete transformation use it + method_transform = getattr(method, "transform", None) + if method_transform is not None: + return method_transform(estimator=self, X=X, y=y) + + # Otherwise use fit_transform over the data + # Note that data leakage is not possible because the matrix only + # depends on the input/output points + return self.fit_transform(X, y) diff --git a/skfda/preprocessing/smoothing/_linear.py b/skfda/preprocessing/smoothing/_linear.py new file mode 100644 index 000000000..730c8fd93 --- /dev/null +++ b/skfda/preprocessing/smoothing/_linear.py @@ -0,0 +1,125 @@ +# -*- coding: utf-8 -*- +"""Linear smoother. + +This module contains the abstract base class for all linear smoothers. + +""" +import abc + +from sklearn.base import BaseEstimator, TransformerMixin + +import numpy as np + +from ... import FDataGrid + + +def _check_r_to_r(f): + if f.ndim_domain != 1 or f.ndim_codomain != 1: + raise NotImplementedError("Only accepts functions from R to R") + + +class _LinearSmoother(abc.ABC, BaseEstimator, TransformerMixin): + """Linear smoother. + + Abstract base class for all linear smoothers. The subclasses must override + ``hat_matrix`` to define the smoothing or 'hat' matrix. + + """ + + def __init__(self, *, + output_points=None): + self.output_points = output_points + + def hat_matrix(self, input_points=None, output_points=None): + cached_input_points = getattr(self, "input_points_", None) + cached_output_points = getattr(self, "output_points_", None) + + # Use the fitted points if they are not provided + if input_points is None: + input_points = cached_input_points + if output_points is None: + output_points = cached_output_points + + if (cached_input_points is not None and + np.array_equal(input_points, cached_input_points) and + np.array_equal(output_points, cached_output_points)): + cached_hat_matrix = getattr(self, "_cached_hat_matrix", None) + if cached_hat_matrix is None: + self.cached_hat_matrix = self._hat_matrix( + input_points=self.input_points_, + output_points=self.output_points_ + ) + return self.cached_hat_matrix + + else: + # We only cache the matrix for the fit points + return self._hat_matrix( + input_points=self.input_points_, + output_points=self.output_points_ + ) + + @abc.abstractmethod + def _hat_matrix(self, input_points, output_points): + pass + + def _more_tags(self): + return { + 'X_types': [] + } + + def fit(self, X: FDataGrid, y=None): + """Compute the hat matrix for the desired output points. + + Args: + X (FDataGrid): + The data whose points are used to compute the matrix. + y : Ignored + Returns: + self (object) + + """ + _check_r_to_r(X) + + self.input_points_ = X.sample_points[0] + self.output_points_ = (self.output_points + if self.output_points is not None + else self.input_points_) + + # Force caching the hat matrix + self.hat_matrix() + + return self + + def transform(self, X: FDataGrid, y=None): + """Multiplies the hat matrix for the functions values to smooth them. + + Args: + X (FDataGrid): + The data to smooth. + y : Ignored + Returns: + self (object) + + """ + + assert all(self.input_points_ == X.sample_points[0]) + + # The matrix is cached + return X.copy(data_matrix=self.hat_matrix() @ X.data_matrix, + sample_points=self.output_points_) + + def score(self, X, y): + """Returns the generalized cross validation (GCV) score. + + Args: + X (FDataGrid): + The data to smooth. + y (FDataGrid): + The target data. Typically the same as ``X``. + Returns: + self (object) + + """ + from .validation import LinearSmootherGeneralizedCVScorer + + return LinearSmootherGeneralizedCVScorer()(self, X, y) diff --git a/skfda/preprocessing/smoothing/kernel_smoothers.py b/skfda/preprocessing/smoothing/kernel_smoothers.py index 445033d24..a35c587f5 100644 --- a/skfda/preprocessing/smoothing/kernel_smoothers.py +++ b/skfda/preprocessing/smoothing/kernel_smoothers.py @@ -5,27 +5,20 @@ So far only non parametric methods are implemented because we are only relying on a discrete representation of functional data. -Todo: - * Closed-form for KNN - """ +import abc + import numpy as np from ...misc import kernels -from sklearn.base import BaseEstimator, TransformerMixin -from skfda.representation.grid import FDataGrid -import abc +from ._linear import _LinearSmoother + __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" -def _check_r_to_r(f): - if f.ndim_domain != 1 or f.ndim_codomain != 1: - raise NotImplementedError("Only accepts functions from R to R") - - -class _LinearKernelSmoother(abc.ABC, BaseEstimator, TransformerMixin): +class _LinearKernelSmoother(_LinearSmoother): def __init__(self, *, smoothing_parameter=None, kernel=kernels.normal, weights=None, @@ -36,6 +29,16 @@ def __init__(self, *, smoothing_parameter=None, self.output_points = output_points self._cv = False # For testing purposes only + def _hat_matrix(self, input_points, output_points): + return self._hat_matrix_function( + input_points=input_points, + output_points=output_points, + smoothing_parameter=self.smoothing_parameter, + kernel=self.kernel, + weights=self.weights, + _cv=self._cv + ) + def _hat_matrix_function(self, *, input_points, output_points, smoothing_parameter, kernel, weights, _cv=False): @@ -72,68 +75,6 @@ def _more_tags(self): 'X_types': [] } - def fit(self, X: FDataGrid, y=None): - """Compute the hat matrix for the desired output points. - - Args: - X (FDataGrid): - The data whose points are used to compute the matrix. - y : Ignored - Returns: - self (object) - - """ - _check_r_to_r(X) - - self.input_points_ = X.sample_points[0] - self.output_points_ = (self.output_points - if self.output_points is not None - else self.input_points_) - - self.hat_matrix_ = self._hat_matrix_function( - input_points=self.input_points_, - output_points=self.output_points_, - smoothing_parameter=self.smoothing_parameter, - kernel=self.kernel, - weights=self.weights, - _cv=self._cv - ) - - return self - - def transform(self, X: FDataGrid, y=None): - """Multiplies the hat matrix for the functions values to smooth them. - - Args: - X (FDataGrid): - The data to smooth. - y : Ignored - Returns: - self (object) - - """ - - assert all(self.input_points_ == X.sample_points[0]) - - return X.copy(data_matrix=self.hat_matrix_ @ X.data_matrix, - sample_points=self.output_points_) - - def score(self, X, y): - """Returns the generalized cross validation (GCV) score. - - Args: - X (FDataGrid): - The data to smooth. - y (FDataGrid): - The target data. Typically the same as ``X``. - Returns: - self (object) - - """ - from .validation import LinearSmootherGeneralizedCVScorer - - return LinearSmootherGeneralizedCVScorer()(self, X, y) - class NadarayaWatsonSmoother(_LinearKernelSmoother): r"""Nadaraya-Watson smoothing method. @@ -165,6 +106,7 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): the input points are used. Examples: + >>> from skfda import FDataGrid >>> fd = FDataGrid(sample_points=[1, 2, 4, 5, 7], ... data_matrix=[[1, 2, 3, 4, 5]]) >>> smoother = NadarayaWatsonSmoother(smoothing_parameter=3.5) @@ -175,7 +117,7 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 3.03], [ 3.24], [ 3.65]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.294, 0.282, 0.204, 0.153, 0.068], [ 0.249, 0.259, 0.22 , 0.179, 0.093], [ 0.165, 0.202, 0.238, 0.229, 0.165], @@ -189,7 +131,7 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 3.09], [ 3.55], [ 4.28]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.425, 0.375, 0.138, 0.058, 0.005], [ 0.309, 0.35 , 0.212, 0.114, 0.015], [ 0.103, 0.193, 0.319, 0.281, 0.103], @@ -210,7 +152,7 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 3.55], [ 3.95], [ 4.28]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.425, 0.375, 0.138, 0.058, 0.005], [ 0.309, 0.35 , 0.212, 0.114, 0.015], [ 0.195, 0.283, 0.283, 0.195, 0.043], @@ -220,6 +162,7 @@ class NadarayaWatsonSmoother(_LinearKernelSmoother): [ 0.006, 0.022, 0.163, 0.305, 0.503]]) """ + def _hat_matrix_function_not_normalized(self, *, delta_x, smoothing_parameter, kernel): @@ -265,6 +208,7 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): the input points are used. Examples: + >>> from skfda import FDataGrid >>> fd = FDataGrid(sample_points=[1, 2, 4, 5, 7], ... data_matrix=[[1, 2, 3, 4, 5]]) >>> smoother = LocalLinearRegressionSmoother(smoothing_parameter=3.5) @@ -275,7 +219,7 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): [ 3.29], [ 4.27], [ 5.08]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.614, 0.429, 0.077, -0.03 , -0.09 ], [ 0.381, 0.595, 0.168, -0. , -0.143], [-0.104, 0.112, 0.697, 0.398, -0.104], @@ -289,7 +233,7 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): [ 3.31], [ 4.04], [ 5.04]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.714, 0.386, -0.037, -0.053, -0.01 ], [ 0.352, 0.724, 0.045, -0.081, -0.04 ], [-0.078, 0.052, 0.74 , 0.364, -0.078], @@ -310,7 +254,7 @@ class LocalLinearRegressionSmoother(_LinearKernelSmoother): [ 4.04], [ 5.35], [ 5.04]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.714, 0.386, -0.037, -0.053, -0.01 ], [ 0.352, 0.724, 0.045, -0.081, -0.04 ], [-0.084, 0.722, 0.722, -0.084, -0.278], @@ -353,6 +297,7 @@ class KNeighborsSmoother(_LinearKernelSmoother): the input points are used. Examples: + >>> from skfda import FDataGrid >>> fd = FDataGrid(sample_points=[1, 2, 4, 5, 7], ... data_matrix=[[1, 2, 3, 4, 5]]) >>> smoother = KNeighborsSmoother(smoothing_parameter=2) @@ -364,7 +309,7 @@ class KNeighborsSmoother(_LinearKernelSmoother): [ 3.5], [ 4.5]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.5, 0.5, 0. , 0. , 0. ], [ 0.5, 0.5, 0. , 0. , 0. ], [ 0. , 0. , 0.5, 0.5, 0. ], @@ -383,7 +328,7 @@ class KNeighborsSmoother(_LinearKernelSmoother): [ 2.5], [ 4. ], [ 4.5]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.5 , 0.5 , 0. , 0. , 0. ], [ 0.333, 0.333, 0.333, 0. , 0. ], [ 0. , 0.5 , 0.5 , 0. , 0. ], @@ -405,7 +350,7 @@ class KNeighborsSmoother(_LinearKernelSmoother): [ 4.5], [ 4.5]]]) - >>> smoother.hat_matrix_.round(3) + >>> smoother.hat_matrix().round(3) array([[ 0.5 , 0.5 , 0. , 0. , 0. ], [ 0.333, 0.333, 0.333, 0. , 0. ], [ 0. , 0.5 , 0.5 , 0. , 0. ], @@ -415,6 +360,7 @@ class KNeighborsSmoother(_LinearKernelSmoother): [ 0. , 0. , 0. , 0.5 , 0.5 ]]) """ + def __init__(self, *, smoothing_parameter=None, kernel=kernels.uniform, weights=None, output_points=None): diff --git a/skfda/preprocessing/smoothing/validation.py b/skfda/preprocessing/smoothing/validation.py index 9d30d998d..9ae5139b2 100644 --- a/skfda/preprocessing/smoothing/validation.py +++ b/skfda/preprocessing/smoothing/validation.py @@ -1,9 +1,8 @@ """Defines methods for the validation of the smoothing.""" -import numpy as np - -from . import kernel_smoothers -from sklearn.model_selection import GridSearchCV import sklearn +from sklearn.model_selection import GridSearchCV + +import numpy as np __author__ = "Miguel Carbajo Berrocal" @@ -18,7 +17,7 @@ def _get_input_estimation_and_matrix(estimator, X): estimator.fit(X) y_est = estimator.transform(X) - hat_matrix = estimator.hat_matrix_ + hat_matrix = estimator.hat_matrix() return y_est, hat_matrix @@ -93,6 +92,7 @@ class LinearSmootherGeneralizedCVScorer(): penalization. """ + def __init__(self, penalization_function=None): self.penalization_function = penalization_function @@ -164,6 +164,7 @@ class SmoothingParameterSearch(GridSearchCV): smoothing by means of the k-nearest neighbours method. >>> import skfda + >>> from skfda.preprocessing.smoothing import kernel_smoothers >>> x = np.linspace(-2, 2, 5) >>> fd = skfda.FDataGrid(x ** 2, x) >>> grid = SmoothingParameterSearch( @@ -175,7 +176,7 @@ class SmoothingParameterSearch(GridSearchCV): -11.67 >>> grid.best_params_['smoothing_parameter'] 2 - >>> grid.best_estimator_.hat_matrix_.round(2) + >>> grid.best_estimator_.hat_matrix().round(2) array([[ 0.5 , 0.5 , 0. , 0. , 0. ], [ 0.33, 0.33, 0.33, 0. , 0. ], [ 0. , 0.33, 0.33, 0.33, 0. ], diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index c66a17a5f..b51bd4557 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -4,27 +4,31 @@ the corresponding basis classes. """ -import copy from abc import ABC, abstractmethod +import copy -import numpy as np +from numpy import polyder, polyint, polymul, polyval +import pandas.api.extensions import scipy.integrate +from scipy.interpolate import BSpline as SciBSpline +from scipy.interpolate import PPoly import scipy.interpolate import scipy.linalg -from numpy import polyder, polyint, polymul, polyval -from scipy.interpolate import PPoly -from scipy.interpolate import BSpline as SciBSpline from scipy.special import binom -from . import grid +import numpy as np + from . import FData +from . import grid from .._utils import _list_of_arrays, constants -import pandas.api.extensions + __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" # aux functions + + def _polypow(p, n=2): if n > 2: return polymul(p, _polypow(p, n - 1)) @@ -325,7 +329,6 @@ def _to_R(self): raise NotImplementedError def _inner_matrix(self, other=None): - r"""Return the Inner Product Matrix of a pair of basis. The Inner Product Matrix is defined as @@ -362,7 +365,6 @@ def _inner_matrix(self, other=None): return inner def gram_matrix(self): - r"""Return the Gram Matrix of a basis The Gram Matrix is defined as @@ -1039,7 +1041,7 @@ def penalty(self, derivative_degree=None, coefficients=None): # Let the ith not be a # Then f(x) = pp(x - a) pp = (PPoly.from_spline((knots, c, self.order - 1)).c[:, - no_0_intervals]) + no_0_intervals]) # We need the actual coefficients of f, not pp. So we # just recursively calculate the new coefficients coeffs = pp.copy() @@ -1060,7 +1062,7 @@ def penalty(self, derivative_degree=None, coefficients=None): for interval in range(len(no_0_intervals)): for i in range(self.nbasis): poly_i = np.trim_zeros(ppoly_lst[i][:, - interval], 'f') + interval], 'f') if len(poly_i) <= derivative_degree: # if the order of the polynomial is lesser or # equal to the derivative the result of the @@ -1075,7 +1077,7 @@ def penalty(self, derivative_degree=None, coefficients=None): for j in range(i + 1, self.nbasis): poly_j = np.trim_zeros(ppoly_lst[j][:, - interval], 'f') + interval], 'f') if len(poly_j) <= derivative_degree: # if the order of the polynomial is lesser # or equal to the derivative the result of @@ -1546,6 +1548,7 @@ class _CoordinateIterator: Dummy object. Should be change to support multidimensional objects. """ + def __init__(self, fdatabasis): """Create an iterator through the image coordinates.""" self._fdatabasis = fdatabasis @@ -1683,6 +1686,32 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, Data Analysis* (pp. 86-87). Springer. """ + from ..preprocessing.smoothing import BasisSmoother + from .grid import FDataGrid + + # n is the samples + # m is the observations + # k is the number of elements of the basis + + # Each sample in a column (m x n) + data_matrix = np.atleast_2d(data_matrix) + + fd = FDataGrid(data_matrix=data_matrix, sample_points=sample_points) + + penalty = (penalty_degree if penalty_degree is not None + else penalty_coefficients) + + smoother = BasisSmoother( + basis=basis, weights=weight_matrix, + smoothing_parameter=smoothness_parameter, + penalty=penalty, + penalty_matrix=penalty_matrix, + method=method, + keepdims=keepdims, + return_basis=True) + + return smoother.fit_transform(fd) + # TODO add an option to return fit summaries: yhat, sse, gcv... if penalty_degree is None and penalty_coefficients is None: penalty_degree = 2 @@ -1871,7 +1900,6 @@ def _evaluate(self, eval_points, *, derivative=0): return res.reshape((self.nsamples, len(eval_points), 1)) def _evaluate_composed(self, eval_points, *, derivative=0): - r"""Evaluate the object or its derivatives at a list of values with a different time for each sample. From fb9780ca72abd4dce5e76ee0545867feb3f3588f Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 4 Jul 2019 15:56:04 +0200 Subject: [PATCH 137/222] Correct bugs in 'matrix' --- skfda/preprocessing/smoothing/_basis.py | 142 ++++++++++++++++++++++-- tests/test_smoothing.py | 9 +- 2 files changed, 135 insertions(+), 16 deletions(-) diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py index a005cbd09..62e24f026 100644 --- a/skfda/preprocessing/smoothing/_basis.py +++ b/skfda/preprocessing/smoothing/_basis.py @@ -4,8 +4,9 @@ This module contains the class for the basis smoothing. """ +import collections from enum import Enum -from typing import Union +from typing import Union, Iterable import scipy.linalg @@ -99,44 +100,161 @@ class _Matrix(): def fit(self, estimator, X, y=None): if estimator.return_basis: estimator._cached_coef_matrix = estimator._coef_matrix( - self.input_points_) + estimator.input_points_) else: # Force caching the hat matrix estimator.hat_matrix() def fit_transform(self, estimator, X, y=None): - return estimator.fit().transform() + return estimator.fit(X, y).transform(X, y) def __call__(self, *, estimator, **_): pass def transform(self, estimator, X, y=None): if estimator.return_basis: - coefficients = estimator._cached_coef_matrix @ X.data_matrix + coefficients = (X.data_matrix[..., 0] + @ estimator._cached_coef_matrix.T) fdatabasis = FDataBasis( - basis=self.basis, coefficients=coefficients, - keepdims=self.keepdims) + basis=estimator.basis, coefficients=coefficients, + keepdims=estimator.keepdims) return fdatabasis else: # The matrix is cached return X.copy(data_matrix=self.hat_matrix() @ X.data_matrix, - sample_points=self.output_points_) + sample_points=estimator.output_points_) class BasisSmoother(_LinearSmoother): + r"""Transform raw data to a smooth functional form. + + Takes functional data in a discrete form and makes an approximates it + to the closest function that can be generated by the basis.a. + + The fit is made so as to reduce the penalized sum of squared errors + [RS05-5-2-5]_: + .. math:: + + PENSSE(c) = (y - \Phi c)' W (y - \Phi c) + \lambda c'Rc + + where :math:`y` is the vector or matrix of observations, :math:`\Phi` + the matrix whose columns are the basis functions evaluated at the + sampling points, :math:`c` the coefficient vector or matrix to be + estimated, :math:`\lambda` a smoothness parameter and :math:`c'Rc` the + matrix representation of the roughness penalty :math:`\int \left[ L( + x(s)) \right] ^2 ds` where :math:`L` is a linear differential operator. + + Each element of :math:`R` has the following close form: + .. math:: + + R_{ij} = \int L\phi_i(s) L\phi_j(s) ds + + By deriving the first formula we obtain the closed formed of the + estimated coefficients matrix: + .. math:: + + \hat(c) = \left( |Phi' W \Phi + \lambda R \right)^{-1} \Phi' W y + + The solution of this matrix equation is done using the cholesky + method for the resolution of a LS problem. If this method throughs a + rounding error warning you may want to use the QR factorisation that + is more numerically stable despite being more expensive to compute. + [RS05-5-2-7]_ + + Args: + basis: (Basis): Basis used. + weights (array_like, optional): Matrix to weight the + observations. Defaults to the identity matrix. + smoothness_parameter (int or float, optional): Smoothness + parameter. Trying with several factors in a logarythm scale is + suggested. If 0 no smoothing is performed. Defaults to 0. + penalty (int, iterable or LinearDifferentialOperator): If it is an + integer, it indicates the order of the + derivative used in the computing of the penalty matrix. For + instance 2 means that the differential operator is + :math:`f''(x)`. If it is an iterable, it consists on coefficients + representing the differential operator used in the computing of + the penalty matrix. For instance the tuple (1, 0, + numpy.sin) means :math:`1 + sin(x)D^{2}`. It is possible to + supply directly the LinearDifferentialOperator object. + If not supplied this defaults to 2. Only used if penalty_matrix is + ``None``. + penalty_matrix (array_like, optional): Penalty matrix. If + supplied the differential operator is not used and instead + the matrix supplied by this argument is used. + method (str): Algorithm used for calculating the coefficients using + the least squares method. The values admitted are 'cholesky', 'qr' + and 'matrix' for Cholesky and QR factorisation methods, and matrix + inversion respectively. The default is 'cholesky'. + return_basis (boolean): If ``False`` (the default) returns the smoothed + data as an FDataGrid, like the other smoothers. If ``True`` returns + a FDataBasis object. + + Returns: + FDataBasis: Represention of the data in a functional form as + product of coefficients by basis functions. + + Examples: + >>> import numpy as np + >>> import skfda + >>> t = np.linspace(0, 1, 5) + >>> x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) + >>> x + array([ 1., 1., -1., -1., 1.]) + + >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) + >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( + ... basis, method='cholesky', return_basis=True) + >>> fd_basis = smoother.fit_transform(fd) + >>> fd_basis.coefficients.round(2) + array([[ 0. , 0.71, 0.71]]) + + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( + ... basis, method='qr', return_basis=True) + >>> fd_basis = smoother.fit_transform(fd) + >>> fd_basis.coefficients.round(2) + array([[-0. , 0.71, 0.71]]) + + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( + ... basis, method='matrix', return_basis=True) + >>> fd_basis = smoother.fit_transform(fd) + >>> fd_basis.coefficients.round(2) + array([[ 0. , 0.71, 0.71]]) + >>> smoother.hat_matrix().round(2) + array([[ 0.43, 0.14, -0.14, 0.14, 0.43], + [ 0.14, 0.71, 0.29, -0.29, 0.14], + [-0.14, 0.29, 0.71, 0.29, -0.14], + [ 0.14, -0.29, 0.29, 0.71, 0.14], + [ 0.43, 0.14, -0.14, 0.14, 0.43]]) + + + References: + .. [RS05-5-2-5] Ramsay, J., Silverman, B. W. (2005). How spline + smooths are computed. In *Functional Data Analysis* + (pp. 86-87). Springer. + + .. [RS05-5-2-7] Ramsay, J., Silverman, B. W. (2005). HSpline + smoothing as an augmented least squares problem. In *Functional + Data Analysis* (pp. 86-87). Springer. + + """ + + _required_parameters = ["basis"] class SolverMethod(Enum): cholesky = _Cholesky() qr = _QR() matrix = _Matrix() - def __init__(self, *, + def __init__(self, basis, + *, smoothing_parameter: float = 0, weights=None, - penalty: Union[int, np.ndarray, + penalty: Union[int, Iterable[float], 'LinearDifferentialOperator'] = None, penalty_matrix=None, output_points=None, @@ -170,7 +288,7 @@ def _penalty(self): penalty = LinearDifferentialOperator(order=2) elif isinstance(self.penalty, int): penalty = LinearDifferentialOperator(order=self.penalty) - elif isinstance(self.penalty, np.ndarray): + elif isinstance(self.penalty, collections.abc.Iterable): penalty = LinearDifferentialOperator(weights=self.penalty) else: penalty = self.penalty @@ -216,12 +334,12 @@ def _coef_matrix(self, input_points): inv = np.linalg.inv(inv) - return inv @ basis_values_input + return inv @ basis_values_input.T @ weight_matrix def _hat_matrix(self, input_points, output_points): basis_values_output = self.basis.evaluate(output_points).T - return basis_values_output.T @ self._coef_matrix(input_points) + return basis_values_output @ self._coef_matrix(input_points) def fit(self, X: FDataGrid, y=None): """Compute the hat matrix for the desired output points. diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py index 76165e3a0..535eba2ed 100644 --- a/tests/test_smoothing.py +++ b/tests/test_smoothing.py @@ -1,12 +1,13 @@ import unittest -from skfda._utils import _check_estimator + +import sklearn + +import numpy as np import skfda +from skfda._utils import _check_estimator import skfda.preprocessing.smoothing.kernel_smoothers as kernel_smoothers import skfda.preprocessing.smoothing.validation as validation -import numpy as np -import sklearn - class TestSklearnEstimators(unittest.TestCase): From 94a83c571a56b8b32fe00d41fe2aabcde7bc70cc Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Mon, 8 Jul 2019 23:05:12 +0200 Subject: [PATCH 138/222] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Carlos Ramos Carreño --- examples/plot_k_neighbors_classification.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/plot_k_neighbors_classification.py b/examples/plot_k_neighbors_classification.py index dc6b4bc1a..0774af372 100644 --- a/examples/plot_k_neighbors_classification.py +++ b/examples/plot_k_neighbors_classification.py @@ -119,7 +119,7 @@ # In this case, we will vary the number of neighbors between 1 and 11. # -# only odd numbers +# Only odd numbers, to prevent ties param_grid = {'n_neighbors': np.arange(1, 12, 2)} @@ -171,7 +171,7 @@ # supported. # # We will fit the model with the sklearn distance and search for the best -# parameter. The results can vary sightly, due to the approximation during +# parameter. The results can vary slightly, due to the approximation during # the integration, but the result should be similar. # From 9f13ec0c9256979be89ca38fa95380b4a9be17cf Mon Sep 17 00:00:00 2001 From: pablomm Date: Wed, 10 Jul 2019 22:46:17 +0200 Subject: [PATCH 139/222] Generation of methods documentation --- docs/conf.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index 2b5222123..e53b0a338 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,6 +55,9 @@ 'sphinx.ext.intersphinx', 'sphinx.ext.doctest' ] +autodoc_default_flags = ['members'] + + doctest_global_setup = ''' import numpy as np np.set_printoptions(legacy='1.13') From dbede182014469309adab212b3fefbe16db309c0 Mon Sep 17 00:00:00 2001 From: pablomm Date: Wed, 10 Jul 2019 23:54:52 +0200 Subject: [PATCH 140/222] Fix sphinks warnings --- docs/modules/representation.rst | 7 ++-- skfda/exploratory/visualization/boxplot.py | 20 +++++++----- skfda/ml/clustering/base_kmeans.py | 4 +-- skfda/representation/_functional_data.py | 34 +++++++++++-------- skfda/representation/basis.py | 38 ++++++++++++---------- skfda/representation/evaluator.py | 38 ++++++++++------------ skfda/representation/grid.py | 10 +++--- 7 files changed, 82 insertions(+), 69 deletions(-) diff --git a/docs/modules/representation.rst b/docs/modules/representation.rst index 01d2a885e..f5c8719ad 100644 --- a/docs/modules/representation.rst +++ b/docs/modules/representation.rst @@ -53,6 +53,7 @@ The following classes are used to define different basis systems. skfda.representation.basis.BSpline skfda.representation.basis.Fourier skfda.representation.basis.Monomial + skfda.representation.basis.Constant Generic representation ---------------------- @@ -67,7 +68,7 @@ receive an element of this class as an argument. :toctree: autosummary skfda.representation.FData - + Extrapolation ------------- All representations of functional data allow evaluation outside of the original @@ -75,5 +76,5 @@ interval using extrapolation methods. .. toctree:: :maxdepth: 4 - - representation/extrapolation \ No newline at end of file + + representation/extrapolation diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/boxplot.py index c98972917..354efc835 100644 --- a/skfda/exploratory/visualization/boxplot.py +++ b/skfda/exploratory/visualization/boxplot.py @@ -110,13 +110,13 @@ class Boxplot(FDataBoxplot): central_regions (array, (fdatagrid.ndim_image * ncentral_regions, 2, nsample_points)): contains the central regions. outliers (array, (fdatagrid.ndim_image, fdatagrid.nsamples)): - contains the outliers + contains the outliers. barcol (string): Color of the envelopes and vertical lines. outliercol (string): Color of the ouliers. mediancol (string): Color of the median. show_full_outliers (boolean): If False (the default) then only the part outside the box is plotted. If True, complete outling curves are - plotted + plotted. Example: Function :math:`f : \mathbb{R}\longmapsto\mathbb{R}`. @@ -328,9 +328,12 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): figure to plot the different dimensions of the image. Only specified if fig and ax are None. - Returns: - fig (figure object): figure object in which the graphs are plotted. - ax (axes object): axes in which the graphs are plotted. + Returns: + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * ax (list): axes in which the graphs are plotted. + """ @@ -625,9 +628,10 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): specified if fig and ax are None. Returns: - fig (figure object): figure object in which the graphs are - plotted. - ax (axes object): axes in which the graphs are plotted. + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * ax (list): axes in which the graphs are plotted. """ fig, ax = self.fdatagrid.generic_plotting_checks(fig, ax, nrows, diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index b0bbefacd..1b070dc18 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -452,7 +452,7 @@ def fit(self, X, y=None, sample_weight=None): Args: X (FDataGrid object): Object whose samples are clusered, classified into different groups. - y (Ignored): present here for API consistency by convention. + y (Ignored): present here for API consistency by convention. sample_weight (Ignored): present here for API consistency by convention. """ @@ -726,7 +726,7 @@ def fit(self, X, y=None, sample_weight=None): Args: X (FDataGrid object): Object whose samples are clusered, classified into different groups. - y (Ignored): present here for API consistency by convention. + y (Ignored): present here for API consistency by convention. sample_weight (Ignored): present here for API consistency by convention. """ diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index f46c68c71..a14585879 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -779,9 +779,8 @@ def set_labels(self, fig=None, ax=None, patches=None): def generic_plotting_checks(self, fig=None, ax=None, nrows=None, ncols=None): - """Check the arguments passed to both :func:`plot - ` and :func:`scatter ` - methods of the FDataGrid object. + """Check the arguments passed to both :func:`plot ` + and :func:`scatter ` methods. Args: fig (figure object, optional): figure over with the graphs are @@ -799,9 +798,10 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, customized in the same call. Returns: - fig (figure object): figure object in which the graphs are plotted - in case ax is None. - ax (axes object): axes in which the graphs are plotted. + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * ax (list): axes in which the graphs are plotted. """ if self.ndim_domain > 2: @@ -851,8 +851,8 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, Args: chart (figure object, axe or list of axes, optional): figure over with the graphs are plotted or axis over where the graphs are - plotted. If None and ax is also None, the figure is - initialized. + plotted. If None and ax is also None, the figure is + initialized. derivative (int or tuple, optional): Order of derivative to be plotted. In case of surfaces a tuple with the order of derivation in each direction can be passed. See @@ -1029,11 +1029,10 @@ def copy(self, **kwargs): """Make a copy of the object. Args: - **kwargs: named args with attributes to be changed in the new copy. + kwargs: named args with attributes to be changed in the new copy. Returns: - FData: A copy of the FData object with the arguments specified - in **kwargs changed. + FData: A copy of the FData object. """ pass @@ -1235,19 +1234,21 @@ def isna(self): return np.ones(self.nsamples, dtype=bool) def take(self, indices, allow_fill=False, fill_value=None): - """ - Take elements from an array. + """Take elements from an array. + Parameters: indices (sequence of integers): Indices to be taken. allow_fill (bool, default False): How to handle negative values in `indices`. + * False: negative values in `indices` indicate positional indices from the right (the default). This is similar to :func:`numpy.take`. * True: negative values in `indices` indicate missing values. These values are set to `fill_value`. Any other negative values raise a ``ValueError``. + fill_value (any, optional): Fill value to use for NA-indices when `allow_fill` is True. This may be ``None``, in which case the default NA value for @@ -1257,17 +1258,21 @@ def take(self, indices, allow_fill=False, fill_value=None): physical NA value. `fill_value` should be the user-facing version, and the implementation should handle translating that to the physical version for processing the take if necessary. + Returns: FData + Raises: IndexError: When the indices are out of bounds for the array. ValueError: When `indices` contains negative values other than - ``-1`` and `allow_fill` is True. + ``-1`` and `allow_fill` is True. + Notes: ExtensionArray.take is called by ``Series.__getitem__``, ``.loc``, ``iloc``, when `indices` is a sequence of values. Additionally, it's called by :meth:`Series.reindex`, or any other method that causes realignment, with a `fill_value`. + See Also: numpy.take pandas.api.extensions.take @@ -1295,6 +1300,7 @@ def _concat_same_type( Parameters: to_concat (sequence of FData) + Returns: FData """ diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index c66a17a5f..38e7fbab3 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -155,8 +155,10 @@ def plot(self, chart=None, *, derivative=0, **kwargs): fdata.plot function. Returns: - fig (figure object): figure object in which the graphs are plotted. - ax (axes object): axes in which the graphs are plotted. + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * ax (list): axes in which the graphs are plotted. """ self.to_basis().plot(chart=chart, derivative=derivative, **kwargs) @@ -500,7 +502,7 @@ def penalty(self, derivative_degree=None, coefficients=None): The differential operator can be either a derivative of a certain degree or a more complex operator. - The penalty matrix is defined as [RS05-5-6-2]_: + The penalty matrix is defined as [RS05-5-6-2-1]_: .. math:: R_{ij} = \int L\phi_i(s) L\phi_j(s) ds @@ -529,7 +531,7 @@ def penalty(self, derivative_degree=None, coefficients=None): array([[ 0.]]) References: - .. [RS05-5-6-2] Ramsay, J., Silverman, B. W. (2005). Specifying the + .. [RS05-5-6-2-1] Ramsay, J., Silverman, B. W. (2005). Specifying the roughness penalty. In *Functional Data Analysis* (pp. 106-107). Springer. @@ -656,7 +658,7 @@ def penalty(self, derivative_degree=None, coefficients=None): The differential operator can be either a derivative of a certain degree or a more complex operator. - The penalty matrix is defined as [RS05-5-6-2]_: + The penalty matrix is defined as [RS05-5-6-2-2]_: .. math:: R_{ij} = \int L\phi_i(s) L\phi_j(s) ds @@ -686,7 +688,7 @@ def penalty(self, derivative_degree=None, coefficients=None): [ 0., 0., 6., 12.]]) References: - .. [RS05-5-6-2] Ramsay, J., Silverman, B. W. (2005). Specifying the + .. [RS05-5-6-2-2] Ramsay, J., Silverman, B. W. (2005). Specifying the roughness penalty. In *Functional Data Analysis* (pp. 106-107). Springer. @@ -967,7 +969,7 @@ def penalty(self, derivative_degree=None, coefficients=None): The differential operator can be either a derivative of a certain degree or a more complex operator. - The penalty matrix is defined as [RS05-5-6-2]_: + The penalty matrix is defined as [RS05-5-6-2-3]_: .. math:: R_{ij} = \int L\phi_i(s) L\phi_j(s) ds @@ -989,7 +991,7 @@ def penalty(self, derivative_degree=None, coefficients=None): numpy.array: Penalty matrix. References: - .. [RS05-5-6-2] Ramsay, J., Silverman, B. W. (2005). Specifying the + .. [RS05-5-6-2-3] Ramsay, J., Silverman, B. W. (2005). Specifying the roughness penalty. In *Functional Data Analysis* (pp. 106-107). Springer. @@ -1399,7 +1401,7 @@ def penalty(self, derivative_degree=None, coefficients=None): The differential operator can be either a derivative of a certain degree or a more complex operator. - The penalty matrix is defined as [RS05-5-6-2]_: + The penalty matrix is defined as [RS05-5-6-2-4]_: .. math:: R_{ij} = \int L\phi_i(s) L\phi_j(s) ds @@ -1421,7 +1423,7 @@ def penalty(self, derivative_degree=None, coefficients=None): numpy.array: Penalty matrix. References: - .. [RS05-5-6-2] Ramsay, J., Silverman, B. W. (2005). Specifying the + .. [RS05-5-6-2-4] Ramsay, J., Silverman, B. W. (2005). Specifying the roughness penalty. In *Functional Data Analysis* (pp. 106-107). Springer. @@ -2186,13 +2188,13 @@ def times(self, other): Args: other (int, list, FDataBasis): Object to multiply with the FDataBasis object. - - int: Multiplies all samples with the value - - list: multiply each values with the samples respectively. - Length should match with FDataBasis samples - - FDataBasis: if there is one sample it multiplies this with - all the samples in the object. If not, it - multiplies each sample respectively. Samples - should match + + * int: Multiplies all samples with the value + * list: multiply each values with the samples respectively. + Length should match with FDataBasis samples + * FDataBasis: if there is one sample it multiplies this with + all the samples in the object. If not, it multiplies each + sample respectively. Samples should match Returns: (FDataBasis): FDataBasis object containing the multiplication @@ -2400,7 +2402,7 @@ def compose(self, fd, *, eval_points=None, **kwargs): fd (:class:`FData`): FData object to make the composition. Should have the same number of samples and image dimension equal to 1. eval_points (array_like): Points to perform the evaluation. - **kwargs: Named arguments to be passed to :func:`from_data`. + kwargs: Named arguments to be passed to :func:`from_data`. """ grid = self.to_grid().compose(fd, eval_points=eval_points) diff --git a/skfda/representation/evaluator.py b/skfda/representation/evaluator.py index 8b76418ae..697848ee8 100644 --- a/skfda/representation/evaluator.py +++ b/skfda/representation/evaluator.py @@ -57,22 +57,21 @@ def evaluate(self, eval_points, *, derivative=0): Evaluates the samples at the same evaluation points. The evaluation call will receive a 2-d array with the evaluation points. - - This method is called internally by :meth:`evaluate` when the argument - `aligned_evaluation` is True. + This method is called internally by :meth:`evaluate` when the + argument ``aligned_evaluation`` is True. Args: eval_points (numpy.ndarray): Numpy array with shape - `(len(eval_points), ndim_domain)` with the evaluation points. - Each entry represents the coordinate of a point. + ``(number_eval_points, ndim_domain)`` with the + evaluation points. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: - (numpy.darray): Numpy 3-d array with shape `(n_samples, - len(eval_points), ndim_image)` with the result of the - evaluation. The entry (i,j,k) will contain the value k-th - image dimension of the i-th sample, at the j-th evaluation - point. + (numpy.darray): Numpy 3d array with shape + ``(n_samples, number_eval_points, ndim_image)`` with the + result of the evaluation. The entry ``(i,j,k)`` will contain + the value k-th image dimension of the i-th sample, at the + j-th evaluation point. """ pass @@ -83,22 +82,21 @@ def evaluate_composed(self, eval_points, *, derivative=0): Evaluates the samples at different evaluation points. The evaluation call will receive a 3-d array with the evaluation points for each - sample. - - This method is called internally by :meth:`evaluate` when the argument - `aligned_evaluation` is False. + sample. This method is called internally by :func:`evaluate` when + the argument ``aligned_evaluation`` is False. Args: eval_points (numpy.ndarray): Numpy array with shape - `(n_samples, number_eval_points, ndim_domain)` with the - evaluation points for each sample. + ``(n_samples, number_eval_points, ndim_domain)`` with the + evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: - (numpy.darray): Numpy 3d array with shape `(n_samples, - number_eval_points, ndim_image)` with the result of the - evaluation. The entry (i,j,k) will contain the value k-th image - dimension of the i-th sample, at the j-th evaluation point. + (numpy.darray): Numpy 3d array with shape + ``(n_samples, number_eval_points, ndim_image)`` with the + result of the evaluation. The entry ``(i,j,k)`` will contain + the value k-th image dimension of the i-th sample, at the + j-th evaluation point. """ pass diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 14faa318e..e28b69e2c 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -771,13 +771,15 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): ncols(int, optional): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - **kwargs: keyword arguments to be passed to the + kwargs: keyword arguments to be passed to the matplotlib.pyplot.scatter function; Returns: - fig (figure object): figure object in which the graphs are plotted - in case ax is None. - ax (axes object): axes in which the graphs are plotted. + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * ax (list): axes in which the graphs are plotted. + """ fig, ax = self.generic_plotting_checks(fig, ax, nrows, ncols) From 2f75a7966bd52e737748e9bbbe03184ca904fe67 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Thu, 11 Jul 2019 11:57:03 +0200 Subject: [PATCH 141/222] Update docs/conf.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Carlos Ramos Carreño --- docs/conf.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index e53b0a338..7f03dc329 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,7 +55,7 @@ 'sphinx.ext.intersphinx', 'sphinx.ext.doctest' ] -autodoc_default_flags = ['members'] +autodoc_default_flags = ['members', 'inherited-members'] doctest_global_setup = ''' From bb6339695aaaa5d9e82ad72b9ae8e1b3a233bc30 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Mon, 15 Jul 2019 22:38:11 +0200 Subject: [PATCH 142/222] Update README.rst --- README.rst | 109 +++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 81 insertions(+), 28 deletions(-) diff --git a/README.rst b/README.rst index 3f9367052..ab3ea0134 100644 --- a/README.rst +++ b/README.rst @@ -1,54 +1,98 @@ .. image:: https://raw.githubusercontent.com/GAA-UAM/scikit-fda/develop/docs/logos/title_logo/title_logo.png :alt: scikit-fda: Functional Data Analysis in Python -scikit-fda -========== +scikit-fda: Functional Data Analysis in Python +=================================================== -|build-status| |docs| |Codecov|_ |PyPi|_ +|python|_ |build-status| |docs| |Codecov|_ |PyPi|_ |license|_ -.. |Codecov| image:: https://codecov.io/gh/GAA-UAM/scikit-fda/branch/develop/graph/badge.svg -.. _Codecov: https://codecov.io/github/GAA-UAM/scikit-fda?branch=develop - -.. |PyPi| image:: https://badge.fury.io/py/scikit-fda.svg -.. _PyPi: https://badge.fury.io/py/scikit-fda +Functional Data Analysis, or FDA, is the field of Statistics that analyses data that +come in the shape of functions. +This package offers classes, methods and functions to give support to FDA +in Python. Includes a wide range of utils to work with functional data, and its +representation, exploration, or preprocessing, among other tasks such as inference, classification, +regression or clustering of functional data. See documentation for further information on the features +included in the package -Functional Data Analysis is the field of Statistics that analyses data that -come in the shape of functions. To know more about fda have a look at fda_ or read [RS05]_. +Documentation +============= -This package offers classes, methods and functions to give support to fda -in Python. The idea is to give a wide range of utils to work with functional data -in Python. This utils include discrete points and basis representation of functional -data and allows to calculate basic statistics such as mean, variance or covariance, -smooth the functional data, calculate norms, distances, perform linear regression and -much more. +The documentation is available at +`fda.readthedocs.io/en/stable/ `_, which +includes detailed information of the different modules, classes and methods available, along with several examples_ +showing different funcionalities. -For more information read the documentation. +The documentation of the latest version, corresponding with the develop version of the package can be found at +`fda.readthedocs.io/en/latest/ `_. Installation ============ +Currently, *scikit-fda* is available in Python 3.6 and 3.7, regardless of the platform. +The latest stable version can be installed via pipy_: + +.. code:: + + pip install scikit-fda + +Installation from source +------------------------ + +It is possible to install the latest version of the package, available in the develop branch, +by cloning this repository and doing a manual installation. + +.. code:: + + git clone https://github.com/GAA-UAM/scikit-fda.git + cd scikit-fda/ + pip install -r requirements.txt # Install dependencies + python setup.py install + +Make sure that your default Python version is currently supported or change the python and pip +commands to specify version, such as ``python3.6``: -Under construction. +.. code:: -.. todo:: installation + git clone https://github.com/GAA-UAM/scikit-fda.git + cd scikit-fda/ + python3.6 -m pip install -r requirements.txt # Install dependencies + python3.6 setup.py install Requirements ------------ +*scikit-fda* depends on the following packages: + +* `setuptools `_ - Python Packaging +* `cython `_ - Python to C compiler +* `numpy `_ - The fundamental package for scientific computing with Python +* `scipy `_ - Scientific computation in Python +* `scikit-learn `_ - Machine learning in Python +* `matplotlib `_ - Plotting with Python +* `mpldatacursor `_ - Interactive data cursors for matplotlib +* `rdata `_ - Reader of R datasets in .rda format in Python + +Contributions +============= +All contributions are welcome. You can help this project grow in multiple ways, from creating an issue, reporting an improvement or bug, to doing a repository fork and creating a pull request to the development branch. -fda is available in Python 3.5 or above, in all operating systems. +All the people involved at some point in the development of the package can be found in the `contributors file `_. -.. todo:: test for other python versions +Citation +======== +If you find this project useful, please cite: -Documentation -============= -The documentation can be found in https://fda.readthedocs.io/en/latest/ +.. todo:: Include citation to scikit-fda paper. -References -========== +License +======= -.. [RS05] Ramsay, J., Silverman, B. W. (2005). *Functional Data Analysis*. Springer. +The package is licensed under the BSD 3-Clause License. A copy of the license_ can be found along with the code. -.. _fda: http://www.functionaldata.org/ +.. _examples: https://fda.readthedocs.io/en/latest/auto_examples/index.html +.. _pipy: https://pypi.org/project/scikit-fda/ + +.. |python| image:: https://img.shields.io/pypi/pyversions/scikit-fda.svg +.. _python: https://badge.fury.io/py/scikit-fda .. |build-status| image:: https://travis-ci.org/GAA-UAM/scikit-fda.svg?branch=develop :alt: build status @@ -59,3 +103,12 @@ References :alt: Documentation Status :scale: 100% :target: http://fda.readthedocs.io/en/latest/?badge=latest + +.. |Codecov| image:: https://codecov.io/gh/GAA-UAM/scikit-fda/branch/develop/graph/badge.svg +.. _Codecov: https://codecov.io/github/GAA-UAM/scikit-fda?branch=develop + +.. |PyPi| image:: https://badge.fury.io/py/scikit-fda.svg +.. _PyPi: https://badge.fury.io/py/scikit-fda + +.. |license| image:: https://img.shields.io/badge/License-BSD%203--Clause-blue.svg +.. _license: https://github.com/GAA-UAM/scikit-fda/blob/master/LICENSE.txt From eb6c180b59e76244a55e646ee3bfbcd93644b11e Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Tue, 16 Jul 2019 21:33:00 +0200 Subject: [PATCH 143/222] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Carlos Ramos Carreño --- README.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index ab3ea0134..adfaaad85 100644 --- a/README.rst +++ b/README.rst @@ -7,13 +7,13 @@ scikit-fda: Functional Data Analysis in Python |python|_ |build-status| |docs| |Codecov|_ |PyPi|_ |license|_ Functional Data Analysis, or FDA, is the field of Statistics that analyses data that -come in the shape of functions. +depend on a continuous parameter. This package offers classes, methods and functions to give support to FDA in Python. Includes a wide range of utils to work with functional data, and its -representation, exploration, or preprocessing, among other tasks such as inference, classification, +representation, exploratory analysis, or preprocessing, among other tasks such as inference, classification, regression or clustering of functional data. See documentation for further information on the features -included in the package +included in the package. Documentation ============= @@ -29,7 +29,7 @@ The documentation of the latest version, corresponding with the develop version Installation ============ Currently, *scikit-fda* is available in Python 3.6 and 3.7, regardless of the platform. -The latest stable version can be installed via pipy_: +The latest stable version can be installed via PyPI_: .. code:: @@ -89,7 +89,7 @@ License The package is licensed under the BSD 3-Clause License. A copy of the license_ can be found along with the code. .. _examples: https://fda.readthedocs.io/en/latest/auto_examples/index.html -.. _pipy: https://pypi.org/project/scikit-fda/ +.. _PyPI: https://pypi.org/project/scikit-fda/ .. |python| image:: https://img.shields.io/pypi/pyversions/scikit-fda.svg .. _python: https://badge.fury.io/py/scikit-fda From 1e92690c78aec98646e5bdb79c7f8b1fe4a018d3 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Tue, 16 Jul 2019 22:06:04 +0200 Subject: [PATCH 144/222] Update README.rst * Fixed wrong link after review * Add scikit-datasets and pandas * Minor style changes --- README.rst | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.rst b/README.rst index adfaaad85..aa00ff9f8 100644 --- a/README.rst +++ b/README.rst @@ -4,7 +4,7 @@ scikit-fda: Functional Data Analysis in Python =================================================== -|python|_ |build-status| |docs| |Codecov|_ |PyPi|_ |license|_ +|python|_ |build-status| |docs| |Codecov|_ |PyPIBadge|_ |license|_ Functional Data Analysis, or FDA, is the field of Statistics that analyses data that depend on a continuous parameter. @@ -20,16 +20,16 @@ Documentation The documentation is available at `fda.readthedocs.io/en/stable/ `_, which -includes detailed information of the different modules, classes and methods available, along with several examples_ +includes detailed information of the different modules, classes and methods of the package, along with several examples_ showing different funcionalities. -The documentation of the latest version, corresponding with the develop version of the package can be found at +The documentation of the latest version, corresponding with the develop version of the package, can be found at `fda.readthedocs.io/en/latest/ `_. Installation ============ Currently, *scikit-fda* is available in Python 3.6 and 3.7, regardless of the platform. -The latest stable version can be installed via PyPI_: +The stable version can be installed via PyPI_: .. code:: @@ -48,8 +48,8 @@ by cloning this repository and doing a manual installation. pip install -r requirements.txt # Install dependencies python setup.py install -Make sure that your default Python version is currently supported or change the python and pip -commands to specify version, such as ``python3.6``: +Make sure that your default Python version is currently supported, or change the python and pip +commands specifying a version, such as ``python3.6``: .. code:: @@ -65,17 +65,21 @@ Requirements * `setuptools `_ - Python Packaging * `cython `_ - Python to C compiler * `numpy `_ - The fundamental package for scientific computing with Python +* `pandas `_ - Powerful Python data analysis toolkit * `scipy `_ - Scientific computation in Python * `scikit-learn `_ - Machine learning in Python * `matplotlib `_ - Plotting with Python * `mpldatacursor `_ - Interactive data cursors for matplotlib * `rdata `_ - Reader of R datasets in .rda format in Python +* `scikit-datasets `_ - Scikit-learn compatible datasets + +The dependencies are automatically installed during the installation. Contributions ============= -All contributions are welcome. You can help this project grow in multiple ways, from creating an issue, reporting an improvement or bug, to doing a repository fork and creating a pull request to the development branch. +All contributions are welcome. You can help this project grow in multiple ways, from creating an issue, reporting an improvement or a bug, to doing a repository fork and creating a pull request to the development branch. -All the people involved at some point in the development of the package can be found in the `contributors file `_. +The people involved at some point in the development of the package can be found in the `contributors file `_. Citation ======== @@ -107,8 +111,8 @@ The package is licensed under the BSD 3-Clause License. A copy of the license_ c .. |Codecov| image:: https://codecov.io/gh/GAA-UAM/scikit-fda/branch/develop/graph/badge.svg .. _Codecov: https://codecov.io/github/GAA-UAM/scikit-fda?branch=develop -.. |PyPi| image:: https://badge.fury.io/py/scikit-fda.svg -.. _PyPi: https://badge.fury.io/py/scikit-fda +.. |PyPIBadge| image:: https://badge.fury.io/py/scikit-fda.svg +.. _PyPIBadge: https://badge.fury.io/py/scikit-fda .. |license| image:: https://img.shields.io/badge/License-BSD%203--Clause-blue.svg .. _license: https://github.com/GAA-UAM/scikit-fda/blob/master/LICENSE.txt From bc592ca5e3f8a5baa9beff53c696a3b311bb6105 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Tue, 16 Jul 2019 22:11:26 +0200 Subject: [PATCH 145/222] Update README.rst Type in sentence --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index aa00ff9f8..c98132a50 100644 --- a/README.rst +++ b/README.rst @@ -49,7 +49,7 @@ by cloning this repository and doing a manual installation. python setup.py install Make sure that your default Python version is currently supported, or change the python and pip -commands specifying a version, such as ``python3.6``: +commands by specifying a version, such as ``python3.6``: .. code:: From ecf1606b3851fd9bd53641feee71bbfb082d653b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 23 Jul 2019 19:07:09 +0200 Subject: [PATCH 146/222] Basis smoother docs. --- docs/modules/preprocessing/smoothing.rst | 30 ++++- skfda/preprocessing/smoothing/_basis.py | 56 ++++++-- skfda/representation/basis.py | 155 ++--------------------- 3 files changed, 80 insertions(+), 161 deletions(-) diff --git a/docs/modules/preprocessing/smoothing.rst b/docs/modules/preprocessing/smoothing.rst index 9a2b72e35..d279405e0 100644 --- a/docs/modules/preprocessing/smoothing.rst +++ b/docs/modules/preprocessing/smoothing.rst @@ -4,6 +4,14 @@ Smoothing Sometimes the functional observations are noisy. The noise can be reduced by smoothing the data. +This module provide several classes, called smoothers, that perform a +smoothing transformation of the data. All of the smoothers follow the +API of an scikit-learn transformer object. + +The degree of smoothing is controlled in all smoothers by an +*smoothing parameter*, named ``smoothing_parameter``, that has different +meaning for each smoother. + Kernel smoothers ---------------- @@ -16,18 +24,28 @@ There are several kernel smoothers provided in this library. All of them are also *linear* smoothers, meaning that they compute a smoothing matrix (or hat matrix) that performs the smoothing as a linear transformation. -All of the smoothers follow the API of an scikit-learn transformer object. - -The degree of smoothing is controlled in all smoother by an -*smoothing parameter*, named ``smoothing_parameter``, that has different -meaning for each smoother. - .. autosummary:: :toctree: autosummary skfda.preprocessing.smoothing.kernel_smoothers.NadarayaWatsonSmoother skfda.preprocessing.smoothing.kernel_smoothers.LocalLinearRegressionSmoother skfda.preprocessing.smoothing.kernel_smoothers.KNeighborsSmoother + +Basis smoother +-------------- + +The basis smoother smooths the data by means of expressing it in a truncated basis +expansion. The data can be further smoothed penalizing its derivatives, using +a linear differential operator. This has the effect of reducing the curvature +of the function and/or its derivatives. + +This smoother is also a linear smoother, although if the QR or Cholesky methods +are used, the matrix does not need to be explicitly computed. + +.. autosummary:: + :toctree: autosummary + + skfda.preprocessing.smoothing.BasisSmoother Validation ---------- diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py index 62e24f026..1b7d529f7 100644 --- a/skfda/preprocessing/smoothing/_basis.py +++ b/skfda/preprocessing/smoothing/_basis.py @@ -135,6 +135,7 @@ class BasisSmoother(_LinearSmoother): The fit is made so as to reduce the penalized sum of squared errors [RS05-5-2-5]_: + .. math:: PENSSE(c) = (y - \Phi c)' W (y - \Phi c) + \lambda c'Rc @@ -147,15 +148,17 @@ class BasisSmoother(_LinearSmoother): x(s)) \right] ^2 ds` where :math:`L` is a linear differential operator. Each element of :math:`R` has the following close form: + .. math:: R_{ij} = \int L\phi_i(s) L\phi_j(s) ds By deriving the first formula we obtain the closed formed of the estimated coefficients matrix: + .. math:: - \hat(c) = \left( |Phi' W \Phi + \lambda R \right)^{-1} \Phi' W y + \hat{c} = \left( \Phi' W \Phi + \lambda R \right)^{-1} \Phi' W y The solution of this matrix equation is done using the cholesky method for the resolution of a LS problem. If this method throughs a @@ -167,11 +170,11 @@ class BasisSmoother(_LinearSmoother): basis: (Basis): Basis used. weights (array_like, optional): Matrix to weight the observations. Defaults to the identity matrix. - smoothness_parameter (int or float, optional): Smoothness + smoothing_parameter (int or float, optional): Smoothing parameter. Trying with several factors in a logarythm scale is suggested. If 0 no smoothing is performed. Defaults to 0. - penalty (int, iterable or LinearDifferentialOperator): If it is an - integer, it indicates the order of the + penalty (int, iterable or :class:`LinearDifferentialOperator`): If it + is an integer, it indicates the order of the derivative used in the computing of the penalty matrix. For instance 2 means that the differential operator is :math:`f''(x)`. If it is an iterable, it consists on coefficients @@ -192,11 +195,9 @@ class BasisSmoother(_LinearSmoother): data as an FDataGrid, like the other smoothers. If ``True`` returns a FDataBasis object. - Returns: - FDataBasis: Represention of the data in a functional form as - product of coefficients by basis functions. - Examples: + By default, the data is converted to basis form without smoothing: + >>> import numpy as np >>> import skfda >>> t = np.linspace(0, 1, 5) @@ -230,6 +231,45 @@ class BasisSmoother(_LinearSmoother): [ 0.14, -0.29, 0.29, 0.71, 0.14], [ 0.43, 0.14, -0.14, 0.14, 0.43]]) + If the smoothing parameter is set to something else than zero, we can + penalize approximations that are not smooth enough using a linear + differential operator: + + >>> from skfda.misc import LinearDifferentialOperator + >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) + >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( + ... basis, method='cholesky', + ... smoothing_parameter=1, + ... penalty=LinearDifferentialOperator(weights=[3, 5]), + ... return_basis=True) + >>> fd_basis = smoother.fit_transform(fd) + >>> fd_basis.coefficients.round(2) + array([[ 0.18, 0.07, 0.09]]) + + >>> from skfda.misc import LinearDifferentialOperator + >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) + >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( + ... basis, method='qr', + ... smoothing_parameter=1, + ... penalty=LinearDifferentialOperator(weights=[3, 5]), + ... return_basis=True) + >>> fd_basis = smoother.fit_transform(fd) + >>> fd_basis.coefficients.round(2) + array([[ 0.18, 0.07, 0.09]]) + + >>> from skfda.misc import LinearDifferentialOperator + >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) + >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( + ... basis, method='matrix', + ... smoothing_parameter=1, + ... penalty=LinearDifferentialOperator(weights=[3, 5]), + ... return_basis=True) + >>> fd_basis = smoother.fit_transform(fd) + >>> fd_basis.coefficients.round(2) + array([[ 0.18, 0.07, 0.09]]) References: .. [RS05-5-2-5] Ramsay, J., Silverman, B. W. (2005). How spline diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index b51bd4557..17591d1b0 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1597,31 +1597,26 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, r"""Transform raw data to a smooth functional form. Takes functional data in a discrete form and makes an approximates it - to the closest function that can be generated by the basis.a + to the closest function that can be generated by the basis. This + function does not attempt to smooth the original data. If smoothing + is desired, it is better to use :class:`BasisSmoother`. - The fit is made so as to reduce the penalized sum of squared errors + The fit is made so as to reduce the sum of squared errors [RS05-5-2-5]_: .. math:: - PENSSE(c) = (y - \Phi c)' W (y - \Phi c) + \lambda c'Rc + SSE(c) = (y - \Phi c)' (y - \Phi c) where :math:`y` is the vector or matrix of observations, :math:`\Phi` the matrix whose columns are the basis functions evaluated at the - sampling points, :math:`c` the coefficient vector or matrix to be - estimated, :math:`\lambda` a smoothness parameter and :math:`c'Rc` the - matrix representation of the roughness penalty :math:`\int \left[ L( - x(s)) \right] ^2 ds` where :math:`L` is a linear differential operator. - - Each element of :math:`R` has the following close form: - .. math:: - - R_{ij} = \int L\phi_i(s) L\phi_j(s) ds + sampling points and :math:`c` the coefficient vector or matrix to be + estimated. By deriving the first formula we obtain the closed formed of the estimated coefficients matrix: .. math:: - \hat(c) = \left( |Phi' W \Phi + \lambda R \right)^{-1} \Phi' W y + \hat(c) = \left( |Phi' \Phi \right)^{-1} \Phi' y The solution of this matrix equation is done using the cholesky method for the resolution of a LS problem. If this method throughs a @@ -1636,25 +1631,6 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, sample_points (array_like): Values of the domain where the previous data were taken. basis: (Basis): Basis used. - weight_matrix (array_like, optional): Matrix to weight the - observations. Defaults to the identity matrix. - smoothness_parameter (int or float, optional): Smoothness - parameter. Trying with several factors in a logarythm scale is - suggested. If 0 no smoothing is performed. Defaults to 0. - penalty_degree (int): Integer indicating the order of the - derivative used in the computing of the penalty matrix. For - instance 2 means that the differential operator is - :math:`f''(x)`. If neither penalty_degree nor - penalty_coefficients are supplied, this defaults to 2. - penalty_coefficients (list): List of coefficients representing the - differential operator used in the computing of the penalty - matrix. An iterable indicating coefficients of derivatives ( - which can be functions). For instance the tuple (1, 0, - numpy.sin) means :math:`1 + sin(x)D^{2}`. Only used if - penalty_degree and penalty_matrix are None. - penalty_matrix (array_like, optional): Penalty matrix. If - supplied the differential operator is not used and instead - the matrix supplied by this argument is used. method (str): Algorithm used for calculating the coefficients using the least squares method. The values admitted are 'cholesky' and 'qr' for Cholesky and QR factorisation methods @@ -1712,121 +1688,6 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, return smoother.fit_transform(fd) - # TODO add an option to return fit summaries: yhat, sse, gcv... - if penalty_degree is None and penalty_coefficients is None: - penalty_degree = 2 - - # n is the samples - # m is the observations - # k is the number of elements of the basis - - # Each sample in a column (m x n) - data_matrix = np.atleast_2d(data_matrix).T - - # Each basis in a column - basis_values = basis.evaluate(sample_points).T - - # If no weight matrix is given all the weights are one - if not weight_matrix: - weight_matrix = np.identity(basis_values.shape[0]) - - # We need to solve the equation - # (phi' W phi + lambda * R) C = phi' W Y - # where: - # phi is the basis_values - # W is the weight matrix - # lambda the smoothness parameter - # C the coefficient matrix (the unknown) - # Y is the data_matrix - - if data_matrix.shape[0] > basis.nbasis or smoothness_parameter > 0: - method = method.lower() - if method == 'cholesky': - right_matrix = basis_values.T @ weight_matrix @ data_matrix - left_matrix = basis_values.T @ weight_matrix @ basis_values - - # Adds the roughness penalty to the equation - if smoothness_parameter > 0: - if not penalty_matrix: - penalty_matrix = basis.penalty(penalty_degree, - penalty_coefficients) - left_matrix += smoothness_parameter * penalty_matrix - - coefficients = scipy.linalg.cho_solve(scipy.linalg.cho_factor( - left_matrix, lower=True), right_matrix) - - # The ith column is the coefficients of the ith basis for each - # sample - coefficients = coefficients.T - - elif method == 'qr': - if weight_matrix is not None: - # Decompose W in U'U and calculate UW and Uy - upper = scipy.linalg.cholesky(weight_matrix) - basis_values = upper @ basis_values - data_matrix = upper @ data_matrix - - if smoothness_parameter > 0: - # In this case instead of resolving the original equation - # we expand the system to include the penalty matrix so - # that the rounding error is reduced - if not penalty_matrix: - penalty_matrix = basis.penalty(penalty_degree, - penalty_coefficients) - - w, v = np.linalg.eigh(penalty_matrix) - # Reduction of the penalty matrix taking away 0 or almost - # zeros eigenvalues - ndegenerated = basis._ndegenerated(penalty_degree) - if ndegenerated: - index = ndegenerated - 1 - else: - index = None - w = w[:index:-1] - v = v[:, :index:-1] - - penalty_matrix = v @ np.diag(np.sqrt(w)) - # Augment the basis matrix with the square root of the - # penalty matrix - basis_values = np.concatenate([ - basis_values, - np.sqrt(smoothness_parameter) * penalty_matrix.T], - axis=0) - # Augment data matrix by n - ndegenerated zeros - data_matrix = np.pad(data_matrix, - ((0, len(v) - ndegenerated), - (0, 0)), - mode='constant') - - # Resolves the equation - # B.T @ B @ C = B.T @ D - # by means of the QR decomposition - - # B = Q @ R - q, r = np.linalg.qr(basis_values) - right_matrix = q.T @ data_matrix - - # R @ C = Q.T @ D - coefficients = np.linalg.solve(r, right_matrix) - # The ith column is the coefficients of the ith basis for each - # sample - coefficients = coefficients.T - - else: - raise ValueError("Unknown method.") - - elif data_matrix.shape[0] == basis.nbasis: - # If the number of basis equals the number of points and no - # smoothing is required - coefficients = np.linalg.solve(basis_values, data_matrix) - - else: # data_matrix.shape[0] < basis.nbasis - raise ValueError(f"The number of basis functions ({basis.nbasis}) " - f"exceed the number of points to be smoothed " - f"({data_matrix.shape[0]}).") - - return cls(basis, coefficients, keepdims=keepdims) - @property def nsamples(self): """Return number of samples.""" From bd6f0978075c4970e680a33d95569c83e54b7b01 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 23 Jul 2019 19:36:06 +0200 Subject: [PATCH 147/222] FDataBasis `from_data` method now does not apply smoothing. --- skfda/preprocessing/smoothing/_basis.py | 2 +- skfda/representation/basis.py | 12 +-- tests/test_basis.py | 109 +++++++++++------------- tests/test_smoothing.py | 53 ++++++++++++ 4 files changed, 107 insertions(+), 69 deletions(-) diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py index 1b7d529f7..d532218e9 100644 --- a/skfda/preprocessing/smoothing/_basis.py +++ b/skfda/preprocessing/smoothing/_basis.py @@ -474,7 +474,7 @@ def fit_transform(self, X: FDataGrid, y=None): elif data_matrix.shape[0] == self.basis.nbasis: # If the number of basis equals the number of points and no # smoothing is required - coefficients = np.linalg.solve(basis_values, data_matrix) + coefficients = np.linalg.solve(basis_values, data_matrix).T else: # data_matrix.shape[0] < basis.nbasis raise ValueError(f"The number of basis functions " diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 17591d1b0..0990fe809 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1590,9 +1590,7 @@ def __init__(self, basis, coefficients, *, dataset_label=None, super().__init__(extrapolation, dataset_label, axes_labels, keepdims) @classmethod - def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, - smoothness_parameter=0, penalty_degree=None, - penalty_coefficients=None, penalty_matrix=None, + def from_data(cls, data_matrix, sample_points, basis, method='cholesky', keepdims=False): r"""Transform raw data to a smooth functional form. @@ -1674,14 +1672,8 @@ def from_data(cls, data_matrix, sample_points, basis, weight_matrix=None, fd = FDataGrid(data_matrix=data_matrix, sample_points=sample_points) - penalty = (penalty_degree if penalty_degree is not None - else penalty_coefficients) - smoother = BasisSmoother( - basis=basis, weights=weight_matrix, - smoothing_parameter=smoothness_parameter, - penalty=penalty, - penalty_matrix=penalty_matrix, + basis=basis, method=method, keepdims=keepdims, return_basis=True) diff --git a/tests/test_basis.py b/tests/test_basis.py index fd383316c..9830be27c 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -1,10 +1,9 @@ import unittest +import numpy as np from skfda.representation.basis import (Basis, FDataBasis, Constant, Monomial, BSpline, Fourier) -import numpy as np - class TestBasis(unittest.TestCase): @@ -15,10 +14,9 @@ def test_from_data_cholesky(self): x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) basis = BSpline((0, 1), nbasis=5) np.testing.assert_array_almost_equal( - FDataBasis.from_data(x, t, basis, smoothness_parameter=10, - penalty_degree=2, method='cholesky' + FDataBasis.from_data(x, t, basis, method='cholesky' ).coefficients.round(2), - np.array([[0.60, 0.47, 0.20, -0.07, -0.20]]) + np.array([[1., 2.78, -3., -0.78, 1.]]) ) def test_from_data_qr(self): @@ -26,25 +24,11 @@ def test_from_data_qr(self): x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) basis = BSpline((0, 1), nbasis=5) np.testing.assert_array_almost_equal( - FDataBasis.from_data(x, t, basis, smoothness_parameter=10, - penalty_degree=2, method='qr' + FDataBasis.from_data(x, t, basis, method='qr' ).coefficients.round(2), - np.array([[0.60, 0.47, 0.20, -0.07, -0.20]]) + np.array([[1., 2.78, -3., -0.78, 1.]]) ) - def test_monomial_smoothing(self): - # It does not have much sense to apply smoothing in this basic case - # where the fit is very good but its just for testing purposes - t = np.linspace(0, 1, 5) - x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) - basis = Monomial(nbasis=4) - fd = FDataBasis.from_data(x, t, basis, - penalty_degree=2, - smoothness_parameter=1) - # These results where extracted from the R package fda - np.testing.assert_array_almost_equal( - fd.coefficients.round(2), np.array([[0.61, -0.88, 0.06, 0.02]])) - def test_bspline_penalty_special_case(self): basis = BSpline(nbasis=5) np.testing.assert_array_almost_equal( @@ -89,7 +73,8 @@ def test_basis_product_generic(self): monomial = Monomial(nbasis=5) fourier = Fourier(nbasis=3) prod = BSpline(nbasis=9, order=8) - self.assertEqual(Basis.default_basis_of_product(monomial, fourier), prod) + self.assertEqual(Basis.default_basis_of_product( + monomial, fourier), prod) def test_basis_constant_product(self): constant = Constant() @@ -123,34 +108,40 @@ def test_basis_monomial_product(self): def test_basis_bspline_product(self): bspline = BSpline(nbasis=6, order=4) - bspline2 = BSpline(domain_range=(0, 1), nbasis=6, order=4, knots=[0, 0.3, 1 / 3, 1]) - prod = BSpline(domain_range=(0,1), nbasis=10, order=7, knots=[0, 0.3, 1/3, 2/3,1]) + bspline2 = BSpline(domain_range=(0, 1), nbasis=6, + order=4, knots=[0, 0.3, 1 / 3, 1]) + prod = BSpline(domain_range=(0, 1), nbasis=10, order=7, + knots=[0, 0.3, 1 / 3, 2 / 3, 1]) self.assertEqual(bspline.basis_of_product(bspline2), prod) def test_basis_inner_matrix(self): np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(), - [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) + [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(Monomial(nbasis=3)), - [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) + [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(Monomial(nbasis=4)), - [[1, 1/2, 1/3, 1/4], [1/2, 1/3, 1/4, 1/5], [1/3, 1/4, 1/5, 1/6]]) + [[1, 1 / 2, 1 / 3, 1 / 4], [1 / 2, 1 / 3, 1 / 4, 1 / 5], [1 / 3, 1 / 4, 1 / 5, 1 / 6]]) # TODO testing with other basis def test_basis_gram_matrix(self): np.testing.assert_array_almost_equal(Monomial(nbasis=3).gram_matrix(), - [[1, 1/2, 1/3], [1/2, 1/3, 1/4], [1/3, 1/4, 1/5]]) + [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) np.testing.assert_almost_equal(Fourier(nbasis=3).gram_matrix(), np.identity(3)) np.testing.assert_almost_equal(BSpline(nbasis=6).gram_matrix().round(4), - np.array([[4.760e-02, 2.920e-02, 6.200e-03, 4.000e-04, 0.000e+00, 0.000e+00], - [2.920e-02, 7.380e-02, 5.210e-02, 1.150e-02, 1.000e-04, 0.000e+00], - [6.200e-03, 5.210e-02, 1.090e-01, 7.100e-02, 1.150e-02, 4.000e-04], - [4.000e-04, 1.150e-02, 7.100e-02, 1.090e-01, 5.210e-02, 6.200e-03], - [0.000e+00, 1.000e-04, 1.150e-02, 5.210e-02, 7.380e-02, 2.920e-02], - [0.000e+00, 0.000e+00, 4.000e-04, 6.200e-03, 2.920e-02, 4.760e-02]])) + np.array([[4.760e-02, 2.920e-02, 6.200e-03, 4.000e-04, 0.000e+00, 0.000e+00], + [2.920e-02, 7.380e-02, 5.210e-02, + 1.150e-02, 1.000e-04, 0.000e+00], + [6.200e-03, 5.210e-02, 1.090e-01, + 7.100e-02, 1.150e-02, 4.000e-04], + [4.000e-04, 1.150e-02, 7.100e-02, + 1.090e-01, 5.210e-02, 6.200e-03], + [0.000e+00, 1.000e-04, 1.150e-02, + 5.210e-02, 7.380e-02, 2.920e-02], + [0.000e+00, 0.000e+00, 4.000e-04, 6.200e-03, 2.920e-02, 4.760e-02]])) def test_basis_basis_inprod(self): monomial = Monomial(nbasis=4) @@ -202,7 +193,8 @@ def test_fdatabasis_fdatabasis_inprod(self): ) np.testing.assert_array_almost_equal( - monomialfd._inner_product_integrate(bsplinefd, None, None).round(3), + monomialfd._inner_product_integrate( + bsplinefd, None, None).round(3), np.array([[16.14797697, 52.81464364, 89.4813103], [11.55565285, 38.22211951, 64.88878618], [18.14698361, 55.64698361, 93.14698361], @@ -227,11 +219,12 @@ def test_fdatabasis_times_fdatabasis_fdatabasis(self): prod_basis = BSpline(nbasis=9, order=6, knots=[0, 0.25, 0.5, 0.75, 1]) prod_coefs = np.array([[0.9788352, 1.6289955, 2.7004969, 6.2678739, - 8.7636441, 4.0069960, 0.7126961, 2.8826708, - 6.0052311]]) + 8.7636441, 4.0069960, 0.7126961, 2.8826708, + 6.0052311]]) self.assertEqual(prod_basis, times_fdar.basis) - np.testing.assert_array_almost_equal(prod_coefs, times_fdar.coefficients) + np.testing.assert_array_almost_equal( + prod_coefs, times_fdar.coefficients) def test_fdatabasis_times_fdatabasis_list(self): monomial = FDataBasis(Monomial(nbasis=3), @@ -276,8 +269,8 @@ def test_fdatabasis__add__(self): [[2, 2, 3], [5, 4, 5]])) np.testing.assert_raises(NotImplementedError, monomial2.__add__, - FDataBasis(Fourier(nbasis=3), - [[2, 2, 3], [5, 4, 5]])) + FDataBasis(Fourier(nbasis=3), + [[2, 2, 3], [5, 4, 5]])) def test_fdatabasis__sub__(self): monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) @@ -302,7 +295,7 @@ def test_fdatabasis__sub__(self): np.testing.assert_raises(NotImplementedError, monomial2.__sub__, FDataBasis(Fourier(nbasis=3), [[2, 2, 3], [5, 4, 5]])) - + def test_fdatabasis__mul__(self): monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) @@ -324,11 +317,10 @@ def test_fdatabasis__mul__(self): [[1, 2, 3], [6, 8, 10]])) np.testing.assert_raises(NotImplementedError, monomial2.__mul__, - FDataBasis(Fourier(nbasis=3), - [[2, 2, 3], [5, 4, 5]])) + FDataBasis(Fourier(nbasis=3), + [[2, 2, 3], [5, 4, 5]])) np.testing.assert_raises(NotImplementedError, monomial2.__mul__, - monomial2) - + monomial2) def test_fdatabasis__mul__(self): monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) @@ -336,23 +328,22 @@ def test_fdatabasis__mul__(self): np.testing.assert_equal(monomial1 / 2, FDataBasis(Monomial(nbasis=3), - [[1/2, 1, 3/2]])) + [[1 / 2, 1, 3 / 2]])) np.testing.assert_equal(monomial2 / 2, FDataBasis(Monomial(nbasis=3), - [[1/2, 1, 3/2], [3/2, 2, 5/2]])) + [[1 / 2, 1, 3 / 2], [3 / 2, 2, 5 / 2]])) np.testing.assert_equal(monomial2 / [1, 2], FDataBasis(Monomial(nbasis=3), - [[1, 2, 3], [3/2, 2, 5/2]])) + [[1, 2, 3], [3 / 2, 2, 5 / 2]])) - def test_fdatabasis_derivative_constant(self): monomial = FDataBasis(Monomial(nbasis=8), [1, 5, 8, 9, 7, 8, 4, 5]) monomial2 = FDataBasis(Monomial(nbasis=5), - [[4, 9, 7, 4, 3], - [1, 7, 9, 8, 5], - [4, 6, 6, 6, 8]]) + [[4, 9, 7, 4, 3], + [1, 7, 9, 8, 5], + [4, 6, 6, 6, 8]]) np.testing.assert_equal(monomial.derivative(), FDataBasis(Monomial(nbasis=7), @@ -378,9 +369,9 @@ def test_fdatabasis_derivative_monomial(self): monomial = FDataBasis(Monomial(nbasis=8), [1, 5, 8, 9, 7, 8, 4, 5]) monomial2 = FDataBasis(Monomial(nbasis=5), - [[4, 9, 7, 4, 3], - [1, 7, 9, 8, 5], - [4, 6, 6, 6, 8]]) + [[4, 9, 7, 4, 3], + [1, 7, 9, 8, 5], + [4, 6, 6, 6, 8]]) np.testing.assert_equal(monomial.derivative(), FDataBasis(Monomial(nbasis=7), @@ -404,7 +395,7 @@ def test_fdatabasis_derivative_monomial(self): def test_fdatabasis_derivative_fourier(self): fourier = FDataBasis(Fourier(nbasis=7), - [1, 5, 8, 9, 8, 4, 5]) + [1, 5, 8, 9, 8, 4, 5]) fourier2 = FDataBasis(Fourier(nbasis=5), [[4, 9, 7, 4, 3], [1, 7, 9, 8, 5], @@ -433,13 +424,15 @@ def test_fdatabasis_derivative_fourier(self): np.testing.assert_equal(fou1.basis, fourier2.basis) np.testing.assert_almost_equal(fou1.coefficients.round(5), [[0, -43.98230, 56.54867, -37.69911, 50.26548], - [0, -56.54867, 43.98230, -62.83185, 100.53096], + [0, -56.54867, 43.98230, - + 62.83185, 100.53096], [0, -37.69911, 37.69911, -100.53096, 75.39822]]) np.testing.assert_equal(fou0, fourier2) np.testing.assert_equal(fou2.basis, fourier2.basis) np.testing.assert_almost_equal(fou2.coefficients.round(5), [[0, -355.30576, -276.34892, -631.65468, -473.74101], - [0, -276.34892, -355.30576, -1263.30936, -789.56835], + [0, -276.34892, -355.30576, - + 1263.30936, -789.56835], [0, -236.87051, -236.87051, -947.48202, -1263.30936]]) def test_fdatabasis_derivative_bspline(self): diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py index 535eba2ed..2db7721a0 100644 --- a/tests/test_smoothing.py +++ b/tests/test_smoothing.py @@ -5,8 +5,11 @@ import numpy as np import skfda from skfda._utils import _check_estimator +import skfda.preprocessing.smoothing as smoothing import skfda.preprocessing.smoothing.kernel_smoothers as kernel_smoothers import skfda.preprocessing.smoothing.validation as validation +from skfda.representation.basis import BSpline, Monomial +from skfda.representation.grid import FDataGrid class TestSklearnEstimators(unittest.TestCase): @@ -65,3 +68,53 @@ def test_local_linear_regression(self): def test_knn(self): self._test_generic(kernel_smoothers.KNeighborsSmoother) + + +class TestBasisSmoother(unittest.TestCase): + + def test_cholesky(self): + t = np.linspace(0, 1, 5) + x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) + basis = BSpline((0, 1), nbasis=5) + fd = FDataGrid(data_matrix=x, sample_points=t) + smoother = smoothing.BasisSmoother(basis=basis, + smoothing_parameter=10, + penalty=2, method='cholesky', + return_basis=True) + fd_basis = smoother.fit_transform(fd) + np.testing.assert_array_almost_equal( + fd_basis.coefficients.round(2), + np.array([[0.60, 0.47, 0.20, -0.07, -0.20]]) + ) + + def test_qr(self): + t = np.linspace(0, 1, 5) + x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) + basis = BSpline((0, 1), nbasis=5) + fd = FDataGrid(data_matrix=x, sample_points=t) + smoother = smoothing.BasisSmoother(basis=basis, + smoothing_parameter=10, + penalty=2, method='qr', + return_basis=True) + fd_basis = smoother.fit_transform(fd) + np.testing.assert_array_almost_equal( + fd_basis.coefficients.round(2), + np.array([[0.60, 0.47, 0.20, -0.07, -0.20]]) + ) + + def test_monomial_smoothing(self): + # It does not have much sense to apply smoothing in this basic case + # where the fit is very good but its just for testing purposes + t = np.linspace(0, 1, 5) + x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) + basis = Monomial(nbasis=4) + fd = FDataGrid(data_matrix=x, sample_points=t) + smoother = smoothing.BasisSmoother(basis=basis, + smoothing_parameter=1, + penalty=2, + return_basis=True) + fd_basis = smoother.fit_transform(fd) + # These results where extracted from the R package fda + np.testing.assert_array_almost_equal( + fd_basis.coefficients.round(2), + np.array([[0.61, -0.88, 0.06, 0.02]])) From 8919b70f029b309fdfd7a37f6cbfca5d5eaef47c Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 23 Jul 2019 19:43:43 +0200 Subject: [PATCH 148/222] Correct formulas --- skfda/representation/basis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index fa4e069ab..c52ec0879 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1603,6 +1603,7 @@ def from_data(cls, data_matrix, sample_points, basis, The fit is made so as to reduce the sum of squared errors [RS05-5-2-5]_: + .. math:: SSE(c) = (y - \Phi c)' (y - \Phi c) @@ -1614,9 +1615,10 @@ def from_data(cls, data_matrix, sample_points, basis, By deriving the first formula we obtain the closed formed of the estimated coefficients matrix: + .. math:: - \hat(c) = \left( |Phi' \Phi \right)^{-1} \Phi' y + \hat{c} = \left( \Phi' \Phi \right)^{-1} \Phi' y The solution of this matrix equation is done using the cholesky method for the resolution of a LS problem. If this method throughs a From aa550e73a387b12ec2160e25b3639bc37ab07e4f Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 25 Jul 2019 13:43:49 +0200 Subject: [PATCH 149/222] Test Pandas integration --- skfda/representation/grid.py | 61 ++++++++++++++++++++------- skfda/representation/interpolation.py | 8 ++-- tests/test_grid.py | 23 +++------- tests/test_pandas.py | 41 ++++++++++++++++++ 4 files changed, 97 insertions(+), 36 deletions(-) create mode 100644 tests/test_pandas.py diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index e28b69e2c..e9a3f2797 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -6,18 +6,18 @@ """ +import copy import numbers -import copy -import numpy as np -import scipy.stats.mstats import pandas.api.extensions +import scipy.stats.mstats +import numpy as np -from . import basis as fdbasis -from .interpolation import SplineInterpolator from . import FData +from . import basis as fdbasis from .._utils import _list_of_arrays, constants +from .interpolation import SplineInterpolator __author__ = "Miguel Carbajo Berrocal" @@ -302,16 +302,6 @@ def coordinates(self): return FDataGrid._CoordinateIterator(self) - @property - def ndim(self): - """Return number of dimensions of the data matrix. - - Returns: - int: Number of dimensions of the data matrix. - - """ - return self.data_matrix.ndim - @property def nsamples(self): """Return number of rows of the data_matrix. Also the number of samples. @@ -580,6 +570,47 @@ def gmean(self): return self.copy(data_matrix=[ scipy.stats.mstats.gmean(self.data_matrix, 0)]) + def __eq__(self, other): + """Comparison of FDataGrid objects""" + if not isinstance(other, FDataGrid): + return NotImplemented + + if not np.array_equal(self.data_matrix, other.data_matrix): + return False + + if len(self.sample_points) != len(other.sample_points): + return False + + for a, b in zip(self.sample_points, other.sample_points): + if not np.array_equal(a, b): + return False + + if not np.array_equal(self.domain_range, other.domain_range): + return False + + if self.dataset_label != other.dataset_label: + return False + + if self.axes_labels is None or other.axes_labels is None: + # Both must be None + if self.axes_labels is not other.axes_labels: + return False + else: + if len(self.axes_labels) != len(other.axes_labels): + return False + + for a, b in zip(self.axes_labels, other.axes_labels): + if a != b: + return False + + if self.extrapolation != other.extrapolation: + return False + + if self.interpolator != other.interpolator: + return False + + return True + def __add__(self, other): """Addition for FDataGrid object. diff --git a/skfda/representation/interpolation.py b/skfda/representation/interpolation.py index 435973bf3..a8ec37f48 100644 --- a/skfda/representation/interpolation.py +++ b/skfda/representation/interpolation.py @@ -3,15 +3,15 @@ """ -import numpy as np - -# Scipy interpolator methods used internally from scipy.interpolate import (PchipInterpolator, UnivariateSpline, RectBivariateSpline, RegularGridInterpolator) +import numpy as np + from .evaluator import Evaluator, EvaluatorConstructor +# Scipy interpolator methods used internally class SplineInterpolator(EvaluatorConstructor): r"""Spline interpolator of :class:`FDataGrid`. @@ -85,7 +85,7 @@ def __eq__(self, other): return (super().__eq__(other) and self.interpolation_order == other.interpolation_order and self.smoothness_parameter == other.smoothness_parameter and - self.monoton == other.monotone) + self.monotone == other.monotone) def evaluator(self, fdatagrid): """Construct a SplineInterpolatorEvaluator used in the evaluation. diff --git a/tests/test_grid.py b/tests/test_grid.py index 483a5cab5..52a31d989 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -1,10 +1,10 @@ import unittest -import numpy as np import scipy.stats.mstats -from skfda.exploratory import stats +import numpy as np from skfda import FDataGrid +from skfda.exploratory import stats class TestFDataGrid(unittest.TestCase): @@ -20,6 +20,10 @@ def test_init(self): np.testing.assert_array_equal( fd.sample_points, np.array([[0., 0.25, 0.5, 0.75, 1.]])) + def test_copy_equals(self): + fd = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) + self.assertEqual(fd, fd.copy()) + def test_mean(self): fd = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) mean = stats.mean(fd) @@ -69,21 +73,6 @@ def test_concatenate(self): [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) np.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) - def test_concatenate(self): - fd1 = FDataGrid([[1, 2, 3, 4, 5], [2, 3, 4, 5, 6]]) - fd2 = FDataGrid([[3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) - - fd1.axes_labels = ["x", "y"] - fd = fd1.concatenate(fd2) - - np.testing.assert_equal(fd.nsamples, 4) - np.testing.assert_equal(fd.ndim_image, 1) - np.testing.assert_equal(fd.ndim_domain, 1) - np.testing.assert_array_equal(fd.data_matrix[..., 0], - [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], - [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) - np.testing.assert_array_equal(fd1.axes_labels, fd.axes_labels) - def test_concatenate_coordinates(self): fd1 = FDataGrid([[1, 2, 3, 4], [2, 3, 4, 5]]) fd2 = FDataGrid([[3, 4, 5, 6], [4, 5, 6, 7]]) diff --git a/tests/test_pandas.py b/tests/test_pandas.py new file mode 100644 index 000000000..90a172537 --- /dev/null +++ b/tests/test_pandas.py @@ -0,0 +1,41 @@ +import unittest + +import pandas as pd +import skfda + + +class TestPandas(unittest.TestCase): + + def setUp(self): + self.fd = skfda.FDataGrid( + [[1, 2, 3, 4, 5, 6, 7], [2, 3, 4, 5, 6, 7, 9]]) + self.fd_basis = self.fd.to_basis(skfda.representation.basis.BSpline( + domain_range=self.fd.domain_range, nbasis=5)) + + def test_fdatagrid_series(self): + series = pd.Series(self.fd) + self.assertEqual( + series.dtype, skfda.representation.grid.FDataGridDType) + self.assertEqual(len(series), self.fd.nsamples) + self.assertEqual(series[0], self.fd[0]) + + def test_fdatabasis_series(self): + series = pd.Series(self.fd_basis) + self.assertEqual( + series.dtype, skfda.representation.basis.FDataBasisDType) + self.assertEqual(len(series), self.fd_basis.nsamples) + self.assertEqual(series[0], self.fd_basis[0]) + + def test_fdatagrid_dataframe(self): + df = pd.DataFrame({"function": self.fd}) + self.assertEqual( + df["function"].dtype, skfda.representation.grid.FDataGridDType) + self.assertEqual(len(df["function"]), self.fd.nsamples) + self.assertEqual(df["function"][0], self.fd[0]) + + def test_fdatabasis_dataframe(self): + df = pd.DataFrame({"function": self.fd_basis}) + self.assertEqual( + df["function"].dtype, skfda.representation.basis.FDataBasisDType) + self.assertEqual(len(df["function"]), self.fd_basis.nsamples) + self.assertEqual(df["function"][0], self.fd_basis[0]) From df043b647bf4f8a6f4875ad9f3448869dc533720 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 25 Jul 2019 15:43:07 +0200 Subject: [PATCH 150/222] Add `axis` parameter to take Remove factorized support --- skfda/representation/_functional_data.py | 42 ++++++++++++++---------- tests/test_pandas.py | 4 +++ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index a14585879..9865c9b8b 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -6,16 +6,15 @@ from abc import ABC, abstractmethod, abstractproperty -import numpy as np - -import matplotlib.patches as mpatches - -import matplotlib.pyplot as plt from matplotlib.axes import Axes import mpl_toolkits.mplot3d import pandas.api.extensions +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt +import numpy as np from skfda.representation.extrapolation import _parse_extrapolation + from .._utils import _coordinate_list, _list_of_arrays, constants @@ -997,7 +996,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, # Selects the number of points if npoints is None: - npoints = 2*(constants.N_POINTS_SURFACE_PLOT_AX,) + npoints = 2 * (constants.N_POINTS_SURFACE_PLOT_AX,) elif np.isscalar(npoints): npoints = (npoints, npoints) elif len(npoints) != 2: @@ -1218,22 +1217,29 @@ def ndim(self): @classmethod def _from_sequence(cls, scalars, dtype=None, copy=False): - return cls(scalars, dtype=dtype) + if copy: + scalars = [f.copy() for f in scalars] + + if dtype is not None and dtype != cls.dtype.fget(None): + raise ValueError(f"Invalid dtype {dtype}") + + return cls._concat_same_type(scalars) @classmethod def _from_factorized(cls, values, original): - return cls(values) + raise NotImplementedError("Factorization does not make sense for " + "functional data") def isna(self): """ A 1-D array indicating if each value is missing. Returns: - na_values (np.ndarray): Array full of True values. + na_values (np.ndarray): Array full of False values. """ - return np.ones(self.nsamples, dtype=bool) + return np.zeros(self.nsamples, dtype=bool) - def take(self, indices, allow_fill=False, fill_value=None): + def take(self, indices, allow_fill=False, fill_value=None, axis=0): """Take elements from an array. Parameters: @@ -1278,6 +1284,12 @@ def take(self, indices, allow_fill=False, fill_value=None): pandas.api.extensions.take """ from pandas.core.algorithms import take + + # The axis parameter must exist, because sklearn tries to use take + # instead of __getitem__ + if axis != 0: + raise ValueError(f"Axis must be 0, not {axis}") + # If the ExtensionArray is backed by an ndarray, then # just pass that here instead of coercing to object. data = self.astype(object) @@ -1307,10 +1319,4 @@ def _concat_same_type( first, *others = to_concat - for o in others: - first = first.concatenate(o) - - # When #101 is ready - # return first.concatenate(others) - - return first + return first.concatenate(*others) diff --git a/tests/test_pandas.py b/tests/test_pandas.py index 90a172537..cf25e9b3a 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -39,3 +39,7 @@ def test_fdatabasis_dataframe(self): df["function"].dtype, skfda.representation.basis.FDataBasisDType) self.assertEqual(len(df["function"]), self.fd_basis.nsamples) self.assertEqual(df["function"][0], self.fd_basis[0]) + + def test_take(self): + self.assertEqual(self.fd.take(0), self.fd[0]) + self.assertEqual(self.fd.take(0, axis=0), self.fd[0]) From 9338427b17ff21f9a7f8d4fa645bef2231fbd83e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Thu, 25 Jul 2019 23:22:16 +0200 Subject: [PATCH 151/222] Update skfda/representation/_functional_data.py Co-Authored-By: Pablo Marcos --- skfda/representation/_functional_data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 9865c9b8b..e423b205d 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -13,7 +13,7 @@ import matplotlib.patches as mpatches import matplotlib.pyplot as plt import numpy as np -from skfda.representation.extrapolation import _parse_extrapolation +from .extrapolation import _parse_extrapolation from .._utils import _coordinate_list, _list_of_arrays, constants From f9fcb5edf6f579411efa8863376eac3c4c2f412a Mon Sep 17 00:00:00 2001 From: pablomm Date: Fri, 26 Jul 2019 19:23:03 +0200 Subject: [PATCH 152/222] Refactor neighbors --- skfda/_neighbors/__init__.py | 20 + skfda/_neighbors/base.py | 624 ++++++++++ skfda/_neighbors/classification.py | 412 +++++++ skfda/_neighbors/outliers.py | 0 skfda/_neighbors/regression.py | 506 ++++++++ skfda/_neighbors/unsupervised.py | 140 +++ skfda/ml/_neighbors.py | 1665 --------------------------- skfda/ml/classification/__init__.py | 4 +- skfda/ml/regression/__init__.py | 9 +- skfda/representation/grid.py | 4 +- tests/test_neighbors.py | 90 +- 11 files changed, 1799 insertions(+), 1675 deletions(-) create mode 100644 skfda/_neighbors/__init__.py create mode 100644 skfda/_neighbors/base.py create mode 100644 skfda/_neighbors/classification.py create mode 100644 skfda/_neighbors/outliers.py create mode 100644 skfda/_neighbors/regression.py create mode 100644 skfda/_neighbors/unsupervised.py delete mode 100644 skfda/ml/_neighbors.py diff --git a/skfda/_neighbors/__init__.py b/skfda/_neighbors/__init__.py new file mode 100644 index 000000000..ab4ebdf52 --- /dev/null +++ b/skfda/_neighbors/__init__.py @@ -0,0 +1,20 @@ +"""Private module with the implementation of the neighbors estimators +Includes the following classes estimators: + - NearestNeighbors + - KNeighborsClassifier + - RadiusNeighborsClassifier + - NearestCentroids + - KNeighborsScalarRegressor + - RadiusNeighborsScalarRegressor + - KNeighborsFunctionalRegressor + - RadiusNeighborsFunctionalRegressor' +""" + +from .unsupervised import NearestNeighbors + +from .classification import (KNeighborsClassifier, RadiusNeighborsClassifier, + NearestCentroids) +from .regression import (KNeighborsFunctionalRegressor, + KNeighborsScalarRegressor, + RadiusNeighborsFunctionalRegressor, + RadiusNeighborsScalarRegressor) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py new file mode 100644 index 000000000..5031a631e --- /dev/null +++ b/skfda/_neighbors/base.py @@ -0,0 +1,624 @@ + + +from abc import ABC, abstractmethod, abstractproperty + +import numpy as np +from sklearn.base import BaseEstimator +from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted +from sklearn.neighbors import NearestNeighbors as _NearestNeighbors + +from .. import FDataGrid +from ..misc.metrics import lp_distance + + +def _to_multivariate(fdatagrid): + r"""Returns the data matrix of a fdatagrid in flatten form compatible with + sklearn. + + Args: + fdatagrid (:class:`FDataGrid`): Grid to be converted to matrix + + Returns: + (np.array): Numpy array with size (nsamples, points), where + points = prod([len(d) for d in fdatagrid.sample_points] + + """ + return fdatagrid.data_matrix.reshape(fdatagrid.nsamples, -1) + + +def _from_multivariate(data_matrix, sample_points, shape, **kwargs): + r"""Constructs a FDatagrid from the data matrix flattened. + + Args: + data_matrix (np.array): Data Matrix flattened as multivariate vector + compatible with sklearn. + sample_points (array_like): List with sample points for each dimension. + shape (tuple): Shape of the data_matrix. + **kwargs: Named params to be passed to the FDataGrid constructor. + + Returns: + (:class:`FDataGrid`): FDatagrid with the data. + + """ + return FDataGrid(data_matrix.reshape(shape), sample_points, **kwargs) + + +def _to_sklearn_metric(metric, sample_points): + r"""Transform a metric between FDatagrid in a sklearn compatible one. + + Given a metric between FDatagrids returns a compatible metric used to + wrap the sklearn routines. + + Args: + metric (pyfunc): Metric of the module `mics.metrics`. Must accept + two FDataGrids and return a float representing the distance. + sample_points (array_like): Array of arrays with the sample points of + the FDataGrids. + check (boolean, optional): If False it is passed the named parameter + `check=False` to avoid the repetition of checks in internal + routines. + + Returns: + (pyfunc): sklearn vector metric. + + Examples: + + >>> import numpy as np + >>> from skfda import FDataGrid + >>> from skfda.misc.metrics import lp_distance + >>> from skfda.ml._neighbors import _to_sklearn_metric + + Calculate the Lp distance between fd and fd2. + + >>> x = np.linspace(0, 1, 101) + >>> fd = FDataGrid([np.ones(len(x))], x) + >>> fd2 = FDataGrid([np.zeros(len(x))], x) + >>> lp_distance(fd, fd2).round(2) + 1.0 + + Creation of the sklearn-style metric. + + >>> sklearn_lp_distance = _to_sklearn_metric(lp_distance, [x]) + >>> sklearn_lp_distance(np.ones(len(x)), np.zeros(len(x))).round(2) + 1.0 + + """ + # Shape -> (Nsamples = 1, domain_dims...., image_dimension (-1)) + shape = [1] + [len(axis) for axis in sample_points] + [-1] + + def sklearn_metric(x, y, check=True, **kwargs): + + return metric(_from_multivariate(x, sample_points, shape), + _from_multivariate(y, sample_points, shape), + check=check, **kwargs) + + return sklearn_metric + + +class NeighborsBase(ABC, BaseEstimator): + """Base class for nearest neighbors estimators.""" + + @abstractmethod + def __init__(self, n_neighbors=None, radius=None, + weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=None, sklearn_metric=False): + + self.n_neighbors = n_neighbors + self.radius = radius + self.weights = weights + self.algorithm = algorithm + self.leaf_size = leaf_size + self.metric = metric + self.metric_params = metric_params + self.n_jobs = n_jobs + self.sklearn_metric = sklearn_metric + + @abstractmethod + def _init_estimator(self, sk_metric): + """Initializes the estimator returned by :meth:`_sklearn_neighbors`.""" + pass + + def _check_is_fitted(self): + """Check if the estimator is fitted. + + Raises: + NotFittedError: If the estimator is not fitted. + + """ + sklearn_check_is_fitted(self, ['estimator_']) + + def _transform_to_multivariate(self, X): + """Transform the input data to array form. If the metric is + precomputed it is not transformed. + + """ + if X is not None and self.metric != 'precomputed': + X = _to_multivariate(X) + + return X + + def _transform_from_multivariate(self, X): + """Transform from array like to FDatagrid.""" + + if X.ndim == 1: + shape = (1, ) + self._shape + else: + shape = (len(X), ) + self._shape + + return _from_multivariate(X, self._sample_points, shape) + +class NeighborsMixin: + """Mixin class to train the neighbors models""" + def fit(self, X, y): + """Fit the model using X as training data and y as target values. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + y (array-like or sparse matrix): Target values of + shape = [n_samples] or [n_samples, n_outputs]. + + Note: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + # If metric is precomputed no diferences with the Sklearn stimator + if self.metric == 'precomputed': + self.estimator_ = self._init_estimator(self.metric) + self.estimator_.fit(X, y) + else: + self._sample_points = X.sample_points + self._shape = X.data_matrix.shape[1:] + + if not self.sklearn_metric: + # Constructs sklearn metric to manage vector + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + else: + sk_metric = self.metric + + self.estimator_ = self._init_estimator(sk_metric) + self.estimator_.fit(self._transform_to_multivariate(X), y) + + return self + + +class KNeighborsMixin: + """Mixin class for K-Neighbors""" + + def kneighbors(self, X=None, n_neighbors=None, return_distance=True): + """Finds the K-neighbors of a point. + Returns indices of and distances to the neighbors of each point. + + Args: + X (:class:`FDataGrid` or matrix): FDatagrid with the query functions + or matrix (n_query, n_indexed) if metric == 'precomputed'. If + not provided, neighbors of each indexed point are returned. In + this case, the query point is not considered its own neighbor. + n_neighbors (int): Number of neighbors to get (default is the value + passed to the constructor). + return_distance (boolean, optional): Defaults to True. If False, + distances will not be returned. + + Returns: + dist : array + Array representing the lengths to points, only present if + return_distance=True + ind : array + Indices of the nearest points in the population matrix. + + Examples: + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors() + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can query the k-nearest neighbors. + + >>> distances, index = neigh.kneighbors(fd[:2]) + >>> index # Index of k-neighbors of samples 0 and 1 + array([[ 0, 7, 6, 11, 2],...) + + >>> distances.round(2) # Distances to k-neighbors + array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], + [ 0. , 0.27, 0.28, 0.29, 0.3 ]]) + + Notes: + This method wraps the corresponding sklearn routine in the + module ``sklearn.neighbors``. + + """ + self._check_is_fitted() + X = self._transform_to_multivariate(X) + + return self.estimator_.kneighbors(X, n_neighbors, return_distance) + + def kneighbors_graph(self, X=None, n_neighbors=None, mode='connectivity'): + """Computes the (weighted) graph of k-Neighbors for points in X + + Args: + X (:class:`FDataGrid` or matrix): FDatagrid with the query functions + or matrix (n_query, n_indexed) if metric == 'precomputed'. If + not provided, neighbors of each indexed point are returned. In + this case, the query point is not considered its own neighbor. + n_neighbors (int): Number of neighbors to get (default is the value + passed to the constructor). + mode ('connectivity' or 'distance', optional): Type of returned + matrix: 'connectivity' will return the connectivity matrix with + ones and zeros, in 'distance' the edges are distance between + points. + + Returns: + Sparse matrix in CSR format, shape = [n_samples, n_samples_fit] + n_samples_fit is the number of samples in the fitted data + A[i, j] is assigned the weight of edge that connects i to j. + + Examples: + Firstly, we will create a toy dataset with 2 classes. + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator. + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors() + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can obtain the graph of k-neighbors of a sample. + + >>> graph = neigh.kneighbors_graph(fd[0]) + >>> print(graph) + (0, 0) 1.0 + (0, 7) 1.0 + (0, 6) 1.0 + (0, 11) 1.0 + (0, 2) 1.0 + + Notes: + This method wraps the corresponding sklearn routine in the + module ``sklearn.neighbors``. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.kneighbors_graph(X, n_neighbors, mode) + + +class RadiusNeighborsMixin: + """Mixin Class for Raius Neighbors""" + + def radius_neighbors(self, X=None, radius=None, return_distance=True): + """Finds the neighbors within a given radius of a fdatagrid or + fdatagrids. + Return the indices and distances of each point from the dataset + lying in a ball with size ``radius`` around the points of the query + array. Points lying on the boundary are included in the results. + The result points are *not* necessarily sorted by distance to their + query point. + + Args: + X (:class:`FDataGrid`, optional): fdatagrid with the sample or + samples whose neighbors will be returned. If not provided, + neighbors of each indexed point are returned. In this case, the + query point is not considered its own neighbor. + radius (float, optional): Limiting distance of neighbors to return. + (default is the value passed to the constructor). + return_distance (boolean, optional). Defaults to True. If False, + distances will not be returned + + Returns + (array, shape (n_samples): dist : array of arrays representing the + distances to each point, only present if return_distance=True. + The distance values are computed according to the ``metric`` + constructor parameter. + (array, shape (n_samples,): An array of arrays of indices of the + approximate nearest points from the population matrix that lie + within a ball of size ``radius`` around the query points. + + Examples: + Firstly, we will create a toy dataset with 2 classes. + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator. + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors(radius=.3) + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can query the neighbors in the radius. + + >>> distances, index = neigh.radius_neighbors(fd[:2]) + >>> index[0] # Neighbors of sample 0 + array([ 0, 2, 6, 7, 11]...) + + >>> distances[0].round(2) # Distances to neighbors of the sample 0 + array([ 0. , 0.3 , 0.29, 0.28, 0.29]) + + + See also: + kneighbors + + Notes: + + Because the number of neighbors of each point is not necessarily + equal, the results for multiple query points cannot be fit in a + standard data array. + For efficiency, `radius_neighbors` returns arrays of objects, where + each object is a 1D array of indices or distances. + + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.radius_neighbors(X=X, radius=radius, + return_distance=return_distance) + + def radius_neighbors_graph(self, X=None, radius=None, mode='connectivity'): + """Computes the (weighted) graph of Neighbors for points in X + Neighborhoods are restricted the points at a distance lower than + radius. + + Args: + X (:class:`FDataGrid`): The query sample or samples. If not + provided, neighbors of each indexed point are returned. In this + case, the query point is not considered its own neighbor. + radius (float): Radius of neighborhoods. (default is the value + passed to the constructor). + mode ('connectivity' or 'distance', optional): Type of returned + matrix: 'connectivity' will return the connectivity matrix with + ones and zeros, in 'distance' the edges are distance between + points. + + Returns: + sparse matrix in CSR format, shape = [n_samples, n_samples] + A[i, j] is assigned the weight of edge that connects i to j. + + Notes: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.radius_neighbors_graph(X=X, radius=radius, + mode=mode) + + +class NeighborsClassifierMixin: + """Mixin class for classifiers based in nearest neighbors""" + + def predict(self, X): + """Predict the class labels for the provided data. + + Args: + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + + Returns: + + (np.array): y : array of shape [n_samples] or + [n_samples, n_outputs] with class labels for each data sample. + + Notes: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict(X) + +class NeighborsScalarRegresorMixin: + """Mixin class for scalar regressor based in nearest neighbors""" + + def predict(self, X): + """Predict the target for the provided data + Parameters + ---------- + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + Returns + ------- + y : array of int, shape = [n_samples] or [n_samples, n_outputs] + Target values + Notes + ----- + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict(X) + +class NearestNeighborsMixinInit: + def _init_estimator(self, sk_metric): + """Initialize the sklearn nearest neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + return _NearestNeighbors( + n_neighbors=self.n_neighbors, radius=self.radius, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + +class NeighborsFunctionalRegressorMixin: + """Mixin class for the functional regressors based in neighbors""" + + def fit(self, X, y): + """Fit the model using X as training data. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + + + """ + if X.nsamples != y.nsamples: + raise ValueError("The response and dependent variable must " + "contain the same number of samples,") + + # If metric is precomputed no different with the Sklearn stimator + if self.metric == 'precomputed': + self.estimator_ = self._init_estimator(self.metric) + self.estimator_.fit(X) + else: + self._sample_points = X.sample_points + self._shape = X.data_matrix.shape[1:] + + if not self.sklearn_metric: + # Constructs sklearn metric to manage vector instead of grids + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + else: + sk_metric = self.metric + + self.estimator_ = self._init_estimator(sk_metric) + self.estimator_.fit(self._transform_to_multivariate(X)) + + # Choose proper local regressor + if self.weights == 'uniform': + self.local_regressor = self._uniform_local_regression + elif self.weights == 'distance': + self.local_regressor = self._distance_local_regression + else: + self.local_regressor = self._weighted_local_regression + + # Store the responses + self._y = y + + return self + + def _uniform_local_regression(self, neighbors, distance=None): + """Perform local regression with uniform weights""" + return self.regressor(neighbors) + + def _distance_local_regression(self, neighbors, distance): + """Perform local regression using distances as weights""" + idx = distance == 0. + if np.any(idx): + weights = distance + weights[idx] = 1. / np.sum(idx) + weights[~idx] = 0. + else: + weights = 1. / distance + weights /= np.sum(weights) + + return self.regressor(neighbors, weights) + + + def _weighted_local_regression(self, neighbors, distance): + """Perform local regression using custom weights""" + + weights = self.weights(distance) + + return self.regressor(neighbors, weights) + + def predict(self, X): + """Predict functional responses. + + Args: + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + + Returns + + y : :class:`FDataGrid` containing as many samples as X. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + distances, neighbors = self._query(X) + + + # Todo: change the concatenation after merge image-operations branch + if len(neighbors[0]) == 0: + pred = self._outlier_response(neighbors) + else: + pred = self.local_regressor(self._y[neighbors[0]], distances[0]) + + for i, idx in enumerate(neighbors[1:]): + if len(idx) == 0: + new_pred = self._outlier_response(neighbors) + else: + new_pred = self.local_regressor(self._y[idx], distances[i+1]) + + pred = pred.concatenate(new_pred) + + return pred + + def _outlier_response(self, neighbors): + """Response in case of no neighbors""" + + if (not hasattr(self, "outlier_response") or + self.outlier_response is None): + index = np.where([len(n)==0 for n in neighbors])[0] + + raise ValueError(f"No neighbors found for test samples {index}, " + "you can try using larger radius, give a reponse " + "for outliers, or consider removing them from your" + " dataset.") + else: + return self.outlier_response + + + @abstractmethod + def _query(self): + """Return distances and neighbors of given sample""" + pass + + def score(self, X, y): + """TODO""" + + # something like + # pred = self.pred(X) + # return score(pred, y) + # + raise NotImplementedError diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py new file mode 100644 index 000000000..257c600bc --- /dev/null +++ b/skfda/_neighbors/classification.py @@ -0,0 +1,412 @@ + +from .base import NeighborsBase, NeighborsMixin, KNeighborsMixin, NeighborsClassifierMixin, RadiusNeighborsMixin + +from sklearn.utils.multiclass import check_classification_targets +from sklearn.preprocessing import LabelEncoder +from sklearn.base import ClassifierMixin, BaseEstimator +from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted + +from sklearn.neighbors import KNeighborsClassifier as _KNeighborsClassifier +from sklearn.neighbors import (RadiusNeighborsClassifier as + _RadiusNeighborsClassifier) + +from ..misc.metrics import lp_distance, pairwise_distance +from ..exploratory.stats import mean + +class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, + ClassifierMixin, NeighborsClassifierMixin): + """Classifier implementing the k-nearest neighbors vote. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + weights : str or callable, optional (default = 'uniform') + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm based on + the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree or KDTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + Doesn't affect :meth:`fit` method. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + >>> y = 15*[0] + 15*[1] + + We will fit a K-Nearest Neighbors classifier + + >>> from skfda.ml.classification import KNeighborsClassifier + >>> neigh = KNeighborsClassifier() + >>> neigh.fit(fd, y) + KNeighborsClassifier(algorithm='auto', leaf_size=30,...) + + We can predict the class of new samples + + >>> neigh.predict(fd[::2]) # Predict labels for even samples + array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + + And the estimated probabilities. + + >>> neigh.predict_proba(fd[0]) # Probabilities of sample 0 + array([[ 1., 0.]]) + + See also + -------- + RadiusNeighborsClassifier + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.KNeighborsClassifier`. + + .. warning:: + Regarding the Nearest Neighbors algorithms, if it is found that two + neighbors, neighbor `k+1` and `k`, have identical distances + but different labels, the results will depend on the ordering of the + training data. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1, sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(n_neighbors=n_neighbors, + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + + def _init_estimator(self, sk_metric): + """Initialize the sklearn K neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + return _KNeighborsClassifier( + n_neighbors=self.n_neighbors, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + + def predict_proba(self, X): + """Return probability estimates for the test data X. + + Args: + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + Returns + p : array of shape = [n_samples, n_classes], or a list of n_outputs + of such arrays if n_outputs > 1. + The class probabilities of the input samples. Classes are + ordered by lexicographic order. + + """ + self._check_is_fitted() + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict_proba(X) + + +class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, + RadiusNeighborsMixin, ClassifierMixin, + NeighborsClassifierMixin): + """Classifier implementing a vote among neighbors within a given radius + + Parameters + ---------- + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + outlier_label : int, optional (default = None) + Label, which is given for outlier samples (samples with no + neighbors on given radius). + If set to None, ValueError is raised, when outlier is detected. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with 2 classes. + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + >>> y = 15*[0] + 15*[1] + + We will fit a Radius Nearest Neighbors classifier. + + >>> from skfda.ml.classification import RadiusNeighborsClassifier + >>> neigh = RadiusNeighborsClassifier(radius=.3) + >>> neigh.fit(fd, y) + RadiusNeighborsClassifier(algorithm='auto', leaf_size=30,...) + + We can predict the class of new samples. + + >>> neigh.predict(fd[::2]) # Predict labels for even samples + array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + + See also + -------- + KNeighborsClassifier + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, radius=1.0, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + outlier_label=None, n_jobs=1, sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(radius=radius, weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + + self.outlier_label = outlier_label + + def _init_estimator(self, sk_metric): + """Initialize the sklearn radius neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn Radius Neighbors estimator initialized. + + """ + return _RadiusNeighborsClassifier( + radius=self.radius, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + outlier_label=self.outlier_label, n_jobs=self.n_jobs) + + +class NearestCentroids(BaseEstimator, ClassifierMixin): + """Nearest centroid classifier for functional data. + + Each class is represented by its centroid, with test samples classified to + the class with the nearest centroid. + + Parameters + ---------- + metric : callable, (default + :func:`lp_distance `) + The metric to use when calculating distance between test samples and + centroids. See the documentation of the metrics module + for a list of available metrics. Defaults used L2 distance. + mean: callable, (default :func:`mean `) + The centroids for the samples corresponding to each class is the + point from which the sum of the distances (according to the metric) + of all samples that belong to that particular class are minimized. + By default it is used the usual mean, which minimizes the sum of L2 + distance. This parameter allows change the centroid constructor. The + function must accept a :class:`FData` with the samples of one class + and return a :class:`FData` object with only one sample representing + the centroid. + Attributes + ---------- + centroids_ : :class:`FDataGrid` + FDatagrid containing the centroid of each class + Examples + -------- + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + >>> y = 15*[0] + 15*[1] + + We will fit a Nearest centroids classifier + + >>> from skfda.ml.classification import NearestCentroids + >>> neigh = NearestCentroids() + >>> neigh.fit(fd, y) + NearestCentroids(...) + + We can predict the class of new samples + + >>> neigh.predict(fd[::2]) # Predict labels for even samples + array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor + NearestNeighbors + + """ + def __init__(self, metric=lp_distance, mean=mean): + """Initialize the classifier.""" + self.metric = metric + self.mean = mean + + def fit(self, X, y): + """Fit the model using X as training data and y as target values. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + y (array-like or sparse matrix): Target values of + shape = [n_samples] or [n_samples, n_outputs]. + + """ + if self.metric == 'precomputed': + raise ValueError("Precomputed is not supported.") + + self._pairwise_distance = pairwise_distance(self.metric) + + check_classification_targets(y) + + le = LabelEncoder() + y_ind = le.fit_transform(y) + self.classes_ = classes = le.classes_ + n_classes = classes.size + if n_classes < 2: + raise ValueError(f'The number of classes has to be greater than' + f' one; got {n_classes} class') + + self.centroids_ = self.mean(X[y_ind == 0]) + + # This could be changed to allow all the concatenation at the same time + # After merge image-operations + for cur_class in range(1, n_classes): + center_mask = y_ind == cur_class + centroid = self.mean(X[center_mask]) + self.centroids_ = self.centroids_.concatenate(centroid) + + return self + + def predict(self, X): + """Predict the class labels for the provided data. + + Args: + X (:class:`FDataGrid`): FDataGrid with the test samples. + + Returns: + + (np.array): y : array of shape [n_samples] or + [n_samples, n_outputs] with class labels for each data sample. + + """ + sklearn_check_is_fitted(self, 'centroids_') + + return self.classes_[self._pairwise_distance( + X, self.centroids_).argmin(axis=1)] diff --git a/skfda/_neighbors/outliers.py b/skfda/_neighbors/outliers.py new file mode 100644 index 000000000..e69de29bb diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py new file mode 100644 index 000000000..dc64c97dd --- /dev/null +++ b/skfda/_neighbors/regression.py @@ -0,0 +1,506 @@ + + +from sklearn.neighbors import KNeighborsRegressor as _KNeighborsRegressor +from sklearn.neighbors import (RadiusNeighborsRegressor as + _RadiusNeighborsRegressor) +from sklearn.base import RegressorMixin + +from .base import (NeighborsBase, NeighborsMixin, + KNeighborsMixin, RadiusNeighborsMixin, + NeighborsScalarRegresorMixin, + NeighborsFunctionalRegressorMixin, + NearestNeighborsMixinInit) + +from ..exploratory.stats import mean +from ..misc.metrics import lp_distance + +class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, + KNeighborsMixin, RegressorMixin, + NeighborsScalarRegresorMixin): + """Regression based on k-nearest neighbors with scalar response. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + weights : str or callable, optional (default = 'uniform') + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm based on + the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree or KDTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + Doesn't affect :meth:`fit` method. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted. + + >>> from skfda.datasets import make_multimodal_samples + >>> from skfda.datasets import make_multimodal_landmarks + >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) + >>> y = y.flatten() + >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + + We will fit a K-Nearest Neighbors regressor to regress a scalar response. + + >>> from skfda.ml.regression import KNeighborsScalarRegressor + >>> neigh = KNeighborsScalarRegressor() + >>> neigh.fit(fd, y) + KNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the modes of new samples + + >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations + array([ 0.79, 0.27, 0.71, 0.79]) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + RadiusNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn regressor + `sklearn.neighbors.KNeighborsRegressor`. + + .. warning:: + Regarding the Nearest Neighbors algorithms, if it is found that two + neighbors, neighbor `k+1` and `k`, have identical distances + but different labels, the results will depend on the ordering of the + training data. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1, sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(n_neighbors=n_neighbors, + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + + def _init_estimator(self, sk_metric): + """Initialize the sklearn K neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + return _KNeighborsRegressor( + n_neighbors=self.n_neighbors, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + +class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, + RadiusNeighborsMixin, RegressorMixin, + NeighborsScalarRegresorMixin): + """Scalar regression based on neighbors within a fixed radius. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted. + + >>> from skfda.datasets import make_multimodal_samples + >>> from skfda.datasets import make_multimodal_landmarks + >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) + >>> y = y.flatten() + >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + + + We will fit a K-Nearest Neighbors regressor to regress a scalar response. + + >>> from skfda.ml.regression import RadiusNeighborsScalarRegressor + >>> neigh = RadiusNeighborsScalarRegressor(radius=.2) + >>> neigh.fit(fd, y) + RadiusNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the modes of new samples. + + >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations + array([ 0.84, 0.27, 0.66, 0.79]) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, radius=1.0, weights='uniform', algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1, sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(radius=radius, weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + + + def _init_estimator(self, sk_metric): + """Initialize the sklearn radius neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn Radius Neighbors estimator initialized. + + """ + return _RadiusNeighborsRegressor( + radius=self.radius, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + +class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, + NeighborsBase, KNeighborsMixin, + NeighborsFunctionalRegressorMixin): + """Functional regression based on neighbors within a fixed radius. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + regressor : callable, optional ((default = + :func:`mean `)) + Function to perform the local regression. By default used the mean. Can + accept a user-defined function wich accepts a :class:`FDataGrid` with + the neighbors of a test sample, and if weights != 'uniform' an array + of weights as second parameter. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted, + and we will try to predict 5 X +1. + + >>> from skfda.datasets import make_multimodal_samples + >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> y_train = 5 * X_train + 1 + >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) + + We will fit a K-Nearest Neighbors functional regressor. + + >>> from skfda.ml.regression import KNeighborsFunctionalRegressor + >>> neigh = KNeighborsFunctionalRegressor() + >>> neigh.fit(X_train, y_train) + KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the response of new samples. + + >>> neigh.predict(X_test) + FDataGrid(...) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + + def __init__(self, n_neighbors=5, weights='uniform', regressor=mean, + algorithm='auto', leaf_size=30, metric=lp_distance, + metric_params=None, n_jobs=1, sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(n_neighbors=n_neighbors, radius=1., + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + self.regressor = regressor + + def _query(self, X): + """Return distances and neighbors of given sample""" + return self.estimator_.kneighbors(X) + +class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, + NeighborsBase, RadiusNeighborsMixin, + NeighborsFunctionalRegressorMixin): + """Functional regression based on neighbors within a fixed radius. + + The target is predicted by local interpolation of the targets + associated of the nearest neighbors in the training set. + + Parameters + ---------- + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + weights : str or callable + weight function used in prediction. Possible values: + + - 'uniform' : uniform weights. All points in each neighborhood + are weighted equally. + - 'distance' : weight points by the inverse of their distance. + in this case, closer neighbors of a query point will have a + greater influence than neighbors which are further away. + - [callable] : a user-defined function which accepts an + array of distances, and returns an array of the same shape + containing the weights. + + Uniform weights are used by default. + regressor : callable, optional ((default = + :func:`mean `)) + Function to perform the local regression. By default used the mean. Can + accept a user-defined function wich accepts a :class:`FDataGrid` with + the neighbors of a test sample, and if weights != 'uniform' an array + of weights as second parameter. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm + based on the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + outlier_response : :class:`FDataGrid`, optional (default = None) + Default response for test samples without neighbors. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with gaussian-like samples shifted, + and we will try to predict 5 X +1. + + >>> from skfda.datasets import make_multimodal_samples + >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> y_train = 5 * X_train + 1 + >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) + + We will fit a Radius Nearest Neighbors functional regressor. + + >>> from skfda.ml.regression import RadiusNeighborsFunctionalRegressor + >>> neigh = RadiusNeighborsFunctionalRegressor(radius=.03) + >>> neigh.fit(X_train, y_train) + RadiusNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + + We can predict the response of new samples. + + >>> neigh.predict(X_test) + FDataGrid(...) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + NearestNeighbors + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.RadiusNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, radius=1., weights='uniform', regressor=mean, + algorithm='auto', leaf_size=30, metric=lp_distance, + metric_params=None, outlier_response=None, n_jobs=1, + sklearn_metric=False): + """Initialize the classifier.""" + + super().__init__(n_neighbors=5, radius=radius, + weights=weights, algorithm=algorithm, + leaf_size=leaf_size, metric=metric, + metric_params=metric_params, n_jobs=n_jobs, + sklearn_metric=sklearn_metric) + self.regressor = regressor + self.outlier_response = outlier_response + + def _query(self, X): + """Return distances and neighbors of given sample""" + return self.estimator_.radius_neighbors(X) diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py new file mode 100644 index 000000000..1a28b4946 --- /dev/null +++ b/skfda/_neighbors/unsupervised.py @@ -0,0 +1,140 @@ + +from .base import (NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, + KNeighborsMixin, RadiusNeighborsMixin, _to_sklearn_metric) + +from ..misc.metrics import lp_distance + +class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, + KNeighborsMixin, RadiusNeighborsMixin): + """Unsupervised learner for implementing neighbor searches. + + Parameters + ---------- + n_neighbors : int, optional (default = 5) + Number of neighbors to use by default for :meth:`kneighbors` queries. + radius : float, optional (default = 1.0) + Range of parameter space to use by default for :meth:`radius_neighbors` + queries. + algorithm : {'auto', 'ball_tree', 'brute'}, optional + Algorithm used to compute the nearest neighbors: + + - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. + - 'brute' will use a brute-force search. + - 'auto' will attempt to decide the most appropriate algorithm based on + the values passed to :meth:`fit` method. + + leaf_size : int, optional (default = 30) + Leaf size passed to BallTree or KDTree. This can affect the + speed of the construction and query, as well as the memory + required to store the tree. The optimal value depends on the + nature of the problem. + metric : string or callable, (default + :func:`lp_distance `) + the distance metric to use for the tree. The default metric is + the Lp distance. See the documentation of the metrics module + for a list of available metrics. + metric_params : dict, optional (default = None) + Additional keyword arguments for the metric function. + n_jobs : int or None, optional (default=None) + The number of parallel jobs to run for neighbors search. + ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. + ``-1`` means using all processors. + Doesn't affect :meth:`fit` method. + sklearn_metric : boolean, optional (default = False) + Indicates if the metric used is a sklearn distance between vectors (see + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the + module :mod:`skfda.misc.metrics`. + Examples + -------- + Firstly, we will create a toy dataset with 2 classes + + >>> from skfda.datasets import make_sinusoidal_process + >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) + >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., + ... phase_std=.25, random_state=0) + >>> fd = fd1.concatenate(fd2) + + We will fit a Nearest Neighbors estimator + + >>> from skfda.ml.classification import NearestNeighbors + >>> neigh = NearestNeighbors(radius=.3) + >>> neigh.fit(fd) + NearestNeighbors(algorithm='auto', leaf_size=30,...) + + Now we can query the k-nearest neighbors. + + >>> distances, index = neigh.kneighbors(fd[:2]) + >>> index # Index of k-neighbors of samples 0 and 1 + array([[ 0, 7, 6, 11, 2],...) + + >>> distances.round(2) # Distances to k-neighbors + array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], + [ 0. , 0.27, 0.28, 0.29, 0.3 ]]) + + We can query the neighbors in a given radius too. + + >>> distances, index = neigh.radius_neighbors(fd[:2]) + >>> index[0] + array([ 0, 2, 6, 7, 11]...) + + >>> distances[0].round(2) # Distances to neighbors of the sample 0 + array([ 0. , 0.3 , 0.29, 0.28, 0.29]) + + See also + -------- + KNeighborsClassifier + RadiusNeighborsClassifier + KNeighborsScalarRegressor + RadiusNeighborsScalarRegressor + NearestCentroids + Notes + ----- + See Nearest Neighbors in the sklearn online documentation for a discussion + of the choice of ``algorithm`` and ``leaf_size``. + + This class wraps the sklearn classifier + `sklearn.neighbors.KNeighborsClassifier`. + + https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + + """ + + def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', + leaf_size=30, metric=lp_distance, metric_params=None, + n_jobs=1, sklearn_metric=False): + """Initialize the nearest neighbors searcher.""" + + super().__init__(n_neighbors=n_neighbors, radius=radius, + algorithm=algorithm, leaf_size=leaf_size, + metric=metric, metric_params=metric_params, + n_jobs=n_jobs, sklearn_metric=sklearn_metric) + + def fit(self, X, y=None): + """Fit the model using X as training data. + + Args: + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + y (None) : Parameter ignored. + + Note: + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + # If metric is precomputed no different with the Sklearn stimator + if self.metric == 'precomputed': + self.estimator_ = self._init_estimator(self.metric) + self.estimator_.fit(X) + else: + self._sample_points = X.sample_points + self._shape = X.data_matrix.shape[1:] + + # Constructs sklearn metric to manage vector instead of FDatagrids + sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + + self.estimator_ = self._init_estimator(sk_metric) + self.estimator_.fit(self._transform_to_multivariate(X)) + + return self diff --git a/skfda/ml/_neighbors.py b/skfda/ml/_neighbors.py deleted file mode 100644 index 70f6039a2..000000000 --- a/skfda/ml/_neighbors.py +++ /dev/null @@ -1,1665 +0,0 @@ -"""Module with classes to neighbors classification and regression.""" - -from abc import ABCMeta, abstractmethod, abstractproperty - -import numpy as np -from sklearn.base import BaseEstimator, ClassifierMixin, RegressorMixin -from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted -from sklearn.utils.multiclass import check_classification_targets -from sklearn.preprocessing import LabelEncoder - -# Sklearn classes to be wrapped -from sklearn.neighbors import NearestNeighbors as _NearestNeighbors -from sklearn.neighbors import KNeighborsClassifier as _KNeighborsClassifier -from sklearn.neighbors import (RadiusNeighborsClassifier as - _RadiusNeighborsClassifier) -from sklearn.neighbors import KNeighborsRegressor as _KNeighborsRegressor -from sklearn.neighbors import (RadiusNeighborsRegressor as - _RadiusNeighborsRegressor) - -from .. import FDataGrid -from ..misc.metrics import lp_distance, pairwise_distance -from ..exploratory.stats import mean - -__all__ = ['NearestNeighbors', 'KNeighborsClassifier', - 'RadiusNeighborsClassifier', 'NearestCentroids', - 'KNeighborsScalarRegressor', 'RadiusNeighborsScalarRegressor', - 'KNeighborsFunctionalRegressor', 'RadiusNeighborsFunctionalRegressor' - ] - -def _to_multivariate(fdatagrid): - r"""Returns the data matrix of a fdatagrid in flatten form compatible with - sklearn. - - Args: - fdatagrid (:class:`FDataGrid`): Grid to be converted to matrix - - Returns: - (np.array): Numpy array with size (nsamples, points), where - points = prod([len(d) for d in fdatagrid.sample_points] - - """ - return fdatagrid.data_matrix.reshape(fdatagrid.nsamples, -1) - - -def _from_multivariate(data_matrix, sample_points, shape, **kwargs): - r"""Constructs a FDatagrid from the data matrix flattened. - - Args: - data_matrix (np.array): Data Matrix flattened as multivariate vector - compatible with sklearn. - sample_points (array_like): List with sample points for each dimension. - shape (tuple): Shape of the data_matrix. - **kwargs: Named params to be passed to the FDataGrid constructor. - - Returns: - (:class:`FDataGrid`): FDatagrid with the data. - - """ - return FDataGrid(data_matrix.reshape(shape), sample_points, **kwargs) - - -def _to_sklearn_metric(metric, sample_points): - r"""Transform a metric between FDatagrid in a sklearn compatible one. - - Given a metric between FDatagrids returns a compatible metric used to - wrap the sklearn routines. - - Args: - metric (pyfunc): Metric of the module `mics.metrics`. Must accept - two FDataGrids and return a float representing the distance. - sample_points (array_like): Array of arrays with the sample points of - the FDataGrids. - check (boolean, optional): If False it is passed the named parameter - `check=False` to avoid the repetition of checks in internal - routines. - - Returns: - (pyfunc): sklearn vector metric. - - Examples: - - >>> import numpy as np - >>> from skfda import FDataGrid - >>> from skfda.misc.metrics import lp_distance - >>> from skfda.ml._neighbors import _to_sklearn_metric - - Calculate the Lp distance between fd and fd2. - - >>> x = np.linspace(0, 1, 101) - >>> fd = FDataGrid([np.ones(len(x))], x) - >>> fd2 = FDataGrid([np.zeros(len(x))], x) - >>> lp_distance(fd, fd2).round(2) - 1.0 - - Creation of the sklearn-style metric. - - >>> sklearn_lp_distance = _to_sklearn_metric(lp_distance, [x]) - >>> sklearn_lp_distance(np.ones(len(x)), np.zeros(len(x))).round(2) - 1.0 - - """ - # Shape -> (Nsamples = 1, domain_dims...., image_dimension (-1)) - shape = [1] + [len(axis) for axis in sample_points] + [-1] - - def sklearn_metric(x, y, check=True, **kwargs): - - return metric(_from_multivariate(x, sample_points, shape), - _from_multivariate(y, sample_points, shape), - check=check, **kwargs) - - return sklearn_metric - - -class NeighborsBase(BaseEstimator, metaclass=ABCMeta): - """Base class for nearest neighbors estimators.""" - - @abstractmethod - def __init__(self, n_neighbors=None, radius=None, - weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=None, sklearn_metric=False): - - self.n_neighbors = n_neighbors - self.radius = radius - self.weights = weights - self.algorithm = algorithm - self.leaf_size = leaf_size - self.metric = metric - self.metric_params = metric_params - self.n_jobs = n_jobs - self.sklearn_metric = sklearn_metric - - @abstractmethod - def _init_estimator(self, sk_metric): - """Initializes the estimator returned by :meth:`_sklearn_neighbors`.""" - pass - - def _check_is_fitted(self): - """Check if the estimator is fitted. - - Raises: - NotFittedError: If the estimator is not fitted. - - """ - sklearn_check_is_fitted(self, ['estimator_']) - - def _transform_to_multivariate(self, X): - """Transform the input data to array form. If the metric is - precomputed it is not transformed. - - """ - if X is not None and self.metric != 'precomputed': - X = _to_multivariate(X) - - return X - - def _transform_from_multivariate(self, X): - """Transform from array like to FDatagrid.""" - - if X.ndim == 1: - shape = (1, ) + self._shape - else: - shape = (len(X), ) + self._shape - - return _from_multivariate(X, self._sample_points, shape) - -class NeighborsMixin: - """Mixin class to train the neighbors models""" - def fit(self, X, y): - """Fit the model using X as training data and y as target values. - - Args: - X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid - with the training data or array matrix with shape - [n_samples, n_samples] if metric='precomputed'. - y (array-like or sparse matrix): Target values of - shape = [n_samples] or [n_samples, n_outputs]. - - Note: - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - # If metric is precomputed no diferences with the Sklearn stimator - if self.metric == 'precomputed': - self.estimator_ = self._init_estimator(self.metric) - self.estimator_.fit(X, y) - else: - self._sample_points = X.sample_points - self._shape = X.data_matrix.shape[1:] - - if not self.sklearn_metric: - # Constructs sklearn metric to manage vector - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) - else: - sk_metric = self.metric - - self.estimator_ = self._init_estimator(sk_metric) - self.estimator_.fit(self._transform_to_multivariate(X), y) - - return self - - -class KNeighborsMixin: - """Mixin class for K-Neighbors""" - - def kneighbors(self, X=None, n_neighbors=None, return_distance=True): - """Finds the K-neighbors of a point. - Returns indices of and distances to the neighbors of each point. - - Args: - X (:class:`FDataGrid` or matrix): FDatagrid with the query functions - or matrix (n_query, n_indexed) if metric == 'precomputed'. If - not provided, neighbors of each indexed point are returned. In - this case, the query point is not considered its own neighbor. - n_neighbors (int): Number of neighbors to get (default is the value - passed to the constructor). - return_distance (boolean, optional): Defaults to True. If False, - distances will not be returned. - - Returns: - dist : array - Array representing the lengths to points, only present if - return_distance=True - ind : array - Indices of the nearest points in the population matrix. - - Examples: - Firstly, we will create a toy dataset with 2 classes - - >>> from skfda.datasets import make_sinusoidal_process - >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) - >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., - ... phase_std=.25, random_state=0) - >>> fd = fd1.concatenate(fd2) - - We will fit a Nearest Neighbors estimator - - >>> from skfda.ml.classification import NearestNeighbors - >>> neigh = NearestNeighbors() - >>> neigh.fit(fd) - NearestNeighbors(algorithm='auto', leaf_size=30,...) - - Now we can query the k-nearest neighbors. - - >>> distances, index = neigh.kneighbors(fd[:2]) - >>> index # Index of k-neighbors of samples 0 and 1 - array([[ 0, 7, 6, 11, 2],...) - - >>> distances.round(2) # Distances to k-neighbors - array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], - [ 0. , 0.27, 0.28, 0.29, 0.3 ]]) - - Notes: - This method wraps the corresponding sklearn routine in the - module ``sklearn.neighbors``. - - """ - self._check_is_fitted() - X = self._transform_to_multivariate(X) - - return self.estimator_.kneighbors(X, n_neighbors, return_distance) - - def kneighbors_graph(self, X=None, n_neighbors=None, mode='connectivity'): - """Computes the (weighted) graph of k-Neighbors for points in X - - Args: - X (:class:`FDataGrid` or matrix): FDatagrid with the query functions - or matrix (n_query, n_indexed) if metric == 'precomputed'. If - not provided, neighbors of each indexed point are returned. In - this case, the query point is not considered its own neighbor. - n_neighbors (int): Number of neighbors to get (default is the value - passed to the constructor). - mode ('connectivity' or 'distance', optional): Type of returned - matrix: 'connectivity' will return the connectivity matrix with - ones and zeros, in 'distance' the edges are distance between - points. - - Returns: - Sparse matrix in CSR format, shape = [n_samples, n_samples_fit] - n_samples_fit is the number of samples in the fitted data - A[i, j] is assigned the weight of edge that connects i to j. - - Examples: - Firstly, we will create a toy dataset with 2 classes. - - >>> from skfda.datasets import make_sinusoidal_process - >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) - >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., - ... phase_std=.25, random_state=0) - >>> fd = fd1.concatenate(fd2) - - We will fit a Nearest Neighbors estimator. - - >>> from skfda.ml.classification import NearestNeighbors - >>> neigh = NearestNeighbors() - >>> neigh.fit(fd) - NearestNeighbors(algorithm='auto', leaf_size=30,...) - - Now we can obtain the graph of k-neighbors of a sample. - - >>> graph = neigh.kneighbors_graph(fd[0]) - >>> print(graph) - (0, 0) 1.0 - (0, 7) 1.0 - (0, 6) 1.0 - (0, 11) 1.0 - (0, 2) 1.0 - - Notes: - This method wraps the corresponding sklearn routine in the - module ``sklearn.neighbors``. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.kneighbors_graph(X, n_neighbors, mode) - - -class RadiusNeighborsMixin: - """Mixin Class for Raius Neighbors""" - - def radius_neighbors(self, X=None, radius=None, return_distance=True): - """Finds the neighbors within a given radius of a fdatagrid or - fdatagrids. - Return the indices and distances of each point from the dataset - lying in a ball with size ``radius`` around the points of the query - array. Points lying on the boundary are included in the results. - The result points are *not* necessarily sorted by distance to their - query point. - - Args: - X (:class:`FDataGrid`, optional): fdatagrid with the sample or - samples whose neighbors will be returned. If not provided, - neighbors of each indexed point are returned. In this case, the - query point is not considered its own neighbor. - radius (float, optional): Limiting distance of neighbors to return. - (default is the value passed to the constructor). - return_distance (boolean, optional). Defaults to True. If False, - distances will not be returned - - Returns - (array, shape (n_samples): dist : array of arrays representing the - distances to each point, only present if return_distance=True. - The distance values are computed according to the ``metric`` - constructor parameter. - (array, shape (n_samples,): An array of arrays of indices of the - approximate nearest points from the population matrix that lie - within a ball of size ``radius`` around the query points. - - Examples: - Firstly, we will create a toy dataset with 2 classes. - - >>> from skfda.datasets import make_sinusoidal_process - >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) - >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., - ... phase_std=.25, random_state=0) - >>> fd = fd1.concatenate(fd2) - - We will fit a Nearest Neighbors estimator. - - >>> from skfda.ml.classification import NearestNeighbors - >>> neigh = NearestNeighbors(radius=.3) - >>> neigh.fit(fd) - NearestNeighbors(algorithm='auto', leaf_size=30,...) - - Now we can query the neighbors in the radius. - - >>> distances, index = neigh.radius_neighbors(fd[:2]) - >>> index[0] # Neighbors of sample 0 - array([ 0, 2, 6, 7, 11]...) - - >>> distances[0].round(2) # Distances to neighbors of the sample 0 - array([ 0. , 0.3 , 0.29, 0.28, 0.29]) - - - See also: - kneighbors - - Notes: - - Because the number of neighbors of each point is not necessarily - equal, the results for multiple query points cannot be fit in a - standard data array. - For efficiency, `radius_neighbors` returns arrays of objects, where - each object is a 1D array of indices or distances. - - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.radius_neighbors(X=X, radius=radius, - return_distance=return_distance) - - def radius_neighbors_graph(self, X=None, radius=None, mode='connectivity'): - """Computes the (weighted) graph of Neighbors for points in X - Neighborhoods are restricted the points at a distance lower than - radius. - - Args: - X (:class:`FDataGrid`): The query sample or samples. If not - provided, neighbors of each indexed point are returned. In this - case, the query point is not considered its own neighbor. - radius (float): Radius of neighborhoods. (default is the value - passed to the constructor). - mode ('connectivity' or 'distance', optional): Type of returned - matrix: 'connectivity' will return the connectivity matrix with - ones and zeros, in 'distance' the edges are distance between - points. - - Returns: - sparse matrix in CSR format, shape = [n_samples, n_samples] - A[i, j] is assigned the weight of edge that connects i to j. - - Notes: - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.radius_neighbors_graph(X=X, radius=radius, - mode=mode) - - -class NeighborsClassifierMixin: - """Mixin class for classifiers based in nearest neighbors""" - - def predict(self, X): - """Predict the class labels for the provided data. - - Args: - X (:class:`FDataGrid` or array-like): FDataGrid with the test - samples or array (n_query, n_indexed) if metric == - 'precomputed'. - - Returns: - - (np.array): y : array of shape [n_samples] or - [n_samples, n_outputs] with class labels for each data sample. - - Notes: - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.predict(X) - -class NeighborsScalarRegresorMixin: - """Mixin class for scalar regressor based in nearest neighbors""" - - def predict(self, X): - """Predict the target for the provided data - Parameters - ---------- - X (:class:`FDataGrid` or array-like): FDataGrid with the test - samples or array (n_query, n_indexed) if metric == - 'precomputed'. - Returns - ------- - y : array of int, shape = [n_samples] or [n_samples, n_outputs] - Target values - Notes - ----- - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.predict(X) - -class NearestNeighborsMixinInit: - def _init_estimator(self, sk_metric): - """Initialize the sklearn nearest neighbors estimator. - - Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn K Neighbors estimator initialized. - - """ - return _NearestNeighbors( - n_neighbors=self.n_neighbors, radius=self.radius, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) - -class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, - KNeighborsMixin, RadiusNeighborsMixin): - """Unsupervised learner for implementing neighbor searches. - - Parameters - ---------- - n_neighbors : int, optional (default = 5) - Number of neighbors to use by default for :meth:`kneighbors` queries. - radius : float, optional (default = 1.0) - Range of parameter space to use by default for :meth:`radius_neighbors` - queries. - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm based on - the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree or KDTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - Doesn't affect :meth:`fit` method. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with 2 classes - - >>> from skfda.datasets import make_sinusoidal_process - >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) - >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., - ... phase_std=.25, random_state=0) - >>> fd = fd1.concatenate(fd2) - - We will fit a Nearest Neighbors estimator - - >>> from skfda.ml.classification import NearestNeighbors - >>> neigh = NearestNeighbors(radius=.3) - >>> neigh.fit(fd) - NearestNeighbors(algorithm='auto', leaf_size=30,...) - - Now we can query the k-nearest neighbors. - - >>> distances, index = neigh.kneighbors(fd[:2]) - >>> index # Index of k-neighbors of samples 0 and 1 - array([[ 0, 7, 6, 11, 2],...) - - >>> distances.round(2) # Distances to k-neighbors - array([[ 0. , 0.28, 0.29, 0.29, 0.3 ], - [ 0. , 0.27, 0.28, 0.29, 0.3 ]]) - - We can query the neighbors in a given radius too. - - >>> distances, index = neigh.radius_neighbors(fd[:2]) - >>> index[0] - array([ 0, 2, 6, 7, 11]...) - - >>> distances[0].round(2) # Distances to neighbors of the sample 0 - array([ 0. , 0.3 , 0.29, 0.28, 0.29]) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.KNeighborsClassifier`. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - - def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1, sklearn_metric=False): - """Initialize the nearest neighbors searcher.""" - - super().__init__(n_neighbors=n_neighbors, radius=radius, - algorithm=algorithm, leaf_size=leaf_size, - metric=metric, metric_params=metric_params, - n_jobs=n_jobs, sklearn_metric=sklearn_metric) - - def fit(self, X, y=None): - """Fit the model using X as training data. - - Args: - X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid - with the training data or array matrix with shape - [n_samples, n_samples] if metric='precomputed'. - y (None) : Parameter ignored. - - Note: - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - # If metric is precomputed no different with the Sklearn stimator - if self.metric == 'precomputed': - self.estimator_ = self._init_estimator(self.metric) - self.estimator_.fit(X) - else: - self._sample_points = X.sample_points - self._shape = X.data_matrix.shape[1:] - - # Constructs sklearn metric to manage vector instead of FDatagrids - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) - - self.estimator_ = self._init_estimator(sk_metric) - self.estimator_.fit(self._transform_to_multivariate(X)) - - return self - - -class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, - ClassifierMixin, NeighborsClassifierMixin): - """Classifier implementing the k-nearest neighbors vote. - - Parameters - ---------- - n_neighbors : int, optional (default = 5) - Number of neighbors to use by default for :meth:`kneighbors` queries. - weights : str or callable, optional (default = 'uniform') - weight function used in prediction. Possible values: - - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. - - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm based on - the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree or KDTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - Doesn't affect :meth:`fit` method. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with 2 classes - - >>> from skfda.datasets import make_sinusoidal_process - >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) - >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., - ... phase_std=.25, random_state=0) - >>> fd = fd1.concatenate(fd2) - >>> y = 15*[0] + 15*[1] - - We will fit a K-Nearest Neighbors classifier - - >>> from skfda.ml.classification import KNeighborsClassifier - >>> neigh = KNeighborsClassifier() - >>> neigh.fit(fd, y) - KNeighborsClassifier(algorithm='auto', leaf_size=30,...) - - We can predict the class of new samples - - >>> neigh.predict(fd[::2]) # Predict labels for even samples - array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - - And the estimated probabilities. - - >>> neigh.predict_proba(fd[0]) # Probabilities of sample 0 - array([[ 1., 0.]]) - - See also - -------- - RadiusNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.KNeighborsClassifier`. - - .. warning:: - Regarding the Nearest Neighbors algorithms, if it is found that two - neighbors, neighbor `k+1` and `k`, have identical distances - but different labels, the results will depend on the ordering of the - training data. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - - def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1, sklearn_metric=False): - """Initialize the classifier.""" - - super().__init__(n_neighbors=n_neighbors, - weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - - def _init_estimator(self, sk_metric): - """Initialize the sklearn K neighbors estimator. - - Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn K Neighbors estimator initialized. - - """ - return _KNeighborsClassifier( - n_neighbors=self.n_neighbors, weights=self.weights, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) - - def predict_proba(self, X): - """Return probability estimates for the test data X. - - Args: - X (:class:`FDataGrid` or array-like): FDataGrid with the test - samples or array (n_query, n_indexed) if metric == - 'precomputed'. - Returns - p : array of shape = [n_samples, n_classes], or a list of n_outputs - of such arrays if n_outputs > 1. - The class probabilities of the input samples. Classes are - ordered by lexicographic order. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.predict_proba(X) - - -class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, - RadiusNeighborsMixin, ClassifierMixin, - NeighborsClassifierMixin): - """Classifier implementing a vote among neighbors within a given radius - - Parameters - ---------- - radius : float, optional (default = 1.0) - Range of parameter space to use by default for :meth:`radius_neighbors` - queries. - weights : str or callable - weight function used in prediction. Possible values: - - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. - - Uniform weights are used by default. - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm - based on the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module - for a list of available metrics. - outlier_label : int, optional (default = None) - Label, which is given for outlier samples (samples with no - neighbors on given radius). - If set to None, ValueError is raised, when outlier is detected. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with 2 classes. - - >>> from skfda.datasets import make_sinusoidal_process - >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) - >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., - ... phase_std=.25, random_state=0) - >>> fd = fd1.concatenate(fd2) - >>> y = 15*[0] + 15*[1] - - We will fit a Radius Nearest Neighbors classifier. - - >>> from skfda.ml.classification import RadiusNeighborsClassifier - >>> neigh = RadiusNeighborsClassifier(radius=.3) - >>> neigh.fit(fd, y) - RadiusNeighborsClassifier(algorithm='auto', leaf_size=30,...) - - We can predict the class of new samples. - - >>> neigh.predict(fd[::2]) # Predict labels for even samples - array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - - See also - -------- - KNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.RadiusNeighborsClassifier`. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - - def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, - outlier_label=None, n_jobs=1, sklearn_metric=False): - """Initialize the classifier.""" - - super().__init__(radius=radius, weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - - self.outlier_label = outlier_label - - def _init_estimator(self, sk_metric): - """Initialize the sklearn radius neighbors estimator. - - Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn Radius Neighbors estimator initialized. - - """ - return _RadiusNeighborsClassifier( - radius=self.radius, weights=self.weights, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - outlier_label=self.outlier_label, n_jobs=self.n_jobs) - - -class NearestCentroids(BaseEstimator, ClassifierMixin): - """Nearest centroid classifier for functional data. - - Each class is represented by its centroid, with test samples classified to - the class with the nearest centroid. - - Parameters - ---------- - metric : callable, (default - :func:`lp_distance `) - The metric to use when calculating distance between test samples and - centroids. See the documentation of the metrics module - for a list of available metrics. Defaults used L2 distance. - mean: callable, (default :func:`mean `) - The centroids for the samples corresponding to each class is the - point from which the sum of the distances (according to the metric) - of all samples that belong to that particular class are minimized. - By default it is used the usual mean, which minimizes the sum of L2 - distance. This parameter allows change the centroid constructor. The - function must accept a :class:`FData` with the samples of one class - and return a :class:`FData` object with only one sample representing - the centroid. - Attributes - ---------- - centroids_ : :class:`FDataGrid` - FDatagrid containing the centroid of each class - Examples - -------- - Firstly, we will create a toy dataset with 2 classes - - >>> from skfda.datasets import make_sinusoidal_process - >>> fd1 = make_sinusoidal_process(phase_std=.25, random_state=0) - >>> fd2 = make_sinusoidal_process(phase_mean=1.8, error_std=0., - ... phase_std=.25, random_state=0) - >>> fd = fd1.concatenate(fd2) - >>> y = 15*[0] + 15*[1] - - We will fit a Nearest centroids classifier - - >>> from skfda.ml.classification import NearestCentroids - >>> neigh = NearestCentroids() - >>> neigh.fit(fd, y) - NearestCentroids(...) - - We can predict the class of new samples - - >>> neigh.predict(fd[::2]) # Predict labels for even samples - array([0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1]) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor - NearestNeighbors - - """ - def __init__(self, metric=lp_distance, mean=mean): - """Initialize the classifier.""" - self.metric = metric - self.mean = mean - - def fit(self, X, y): - """Fit the model using X as training data and y as target values. - - Args: - X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid - with the training data or array matrix with shape - [n_samples, n_samples] if metric='precomputed'. - y (array-like or sparse matrix): Target values of - shape = [n_samples] or [n_samples, n_outputs]. - - """ - if self.metric == 'precomputed': - raise ValueError("Precomputed is not supported.") - - self._pairwise_distance = pairwise_distance(self.metric) - - check_classification_targets(y) - - le = LabelEncoder() - y_ind = le.fit_transform(y) - self.classes_ = classes = le.classes_ - n_classes = classes.size - if n_classes < 2: - raise ValueError(f'The number of classes has to be greater than' - f' one; got {n_classes} class') - - self.centroids_ = self.mean(X[y_ind == 0]) - - # This could be changed to allow all the concatenation at the same time - # After merge image-operations - for cur_class in range(1, n_classes): - center_mask = y_ind == cur_class - centroid = self.mean(X[center_mask]) - self.centroids_ = self.centroids_.concatenate(centroid) - - return self - - def predict(self, X): - """Predict the class labels for the provided data. - - Args: - X (:class:`FDataGrid`): FDataGrid with the test samples. - - Returns: - - (np.array): y : array of shape [n_samples] or - [n_samples, n_outputs] with class labels for each data sample. - - """ - sklearn_check_is_fitted(self, 'centroids_') - - return self.classes_[self._pairwise_distance( - X, self.centroids_).argmin(axis=1)] - -class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, - KNeighborsMixin, RegressorMixin, - NeighborsScalarRegresorMixin): - """Regression based on k-nearest neighbors with scalar response. - - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. - - Parameters - ---------- - n_neighbors : int, optional (default = 5) - Number of neighbors to use by default for :meth:`kneighbors` queries. - weights : str or callable, optional (default = 'uniform') - weight function used in prediction. Possible values: - - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. - - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm based on - the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree or KDTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - Doesn't affect :meth:`fit` method. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with gaussian-like samples shifted. - - >>> from skfda.datasets import make_multimodal_samples - >>> from skfda.datasets import make_multimodal_landmarks - >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) - >>> y = y.flatten() - >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) - - We will fit a K-Nearest Neighbors regressor to regress a scalar response. - - >>> from skfda.ml.regression import KNeighborsScalarRegressor - >>> neigh = KNeighborsScalarRegressor() - >>> neigh.fit(fd, y) - KNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) - - We can predict the modes of new samples - - >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations - array([ 0.79, 0.27, 0.71, 0.79]) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - RadiusNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn regressor - `sklearn.neighbors.KNeighborsRegressor`. - - .. warning:: - Regarding the Nearest Neighbors algorithms, if it is found that two - neighbors, neighbor `k+1` and `k`, have identical distances - but different labels, the results will depend on the ordering of the - training data. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1, sklearn_metric=False): - """Initialize the classifier.""" - - super().__init__(n_neighbors=n_neighbors, - weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - - def _init_estimator(self, sk_metric): - """Initialize the sklearn K neighbors estimator. - - Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn K Neighbors estimator initialized. - - """ - return _KNeighborsRegressor( - n_neighbors=self.n_neighbors, weights=self.weights, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) - -class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, - RadiusNeighborsMixin, RegressorMixin, - NeighborsScalarRegresorMixin): - """Scalar regression based on neighbors within a fixed radius. - - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. - - Parameters - ---------- - radius : float, optional (default = 1.0) - Range of parameter space to use by default for :meth:`radius_neighbors` - queries. - weights : str or callable - weight function used in prediction. Possible values: - - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. - - Uniform weights are used by default. - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm - based on the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with gaussian-like samples shifted. - - >>> from skfda.datasets import make_multimodal_samples - >>> from skfda.datasets import make_multimodal_landmarks - >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) - >>> y = y.flatten() - >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) - - - We will fit a K-Nearest Neighbors regressor to regress a scalar response. - - >>> from skfda.ml.regression import RadiusNeighborsScalarRegressor - >>> neigh = RadiusNeighborsScalarRegressor(radius=.2) - >>> neigh.fit(fd, y) - RadiusNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) - - We can predict the modes of new samples. - - >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations - array([ 0.84, 0.27, 0.66, 0.79]) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.RadiusNeighborsClassifier`. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - - def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, - n_jobs=1, sklearn_metric=False): - """Initialize the classifier.""" - - super().__init__(radius=radius, weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - - - def _init_estimator(self, sk_metric): - """Initialize the sklearn radius neighbors estimator. - - Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn Radius Neighbors estimator initialized. - - """ - return _RadiusNeighborsRegressor( - radius=self.radius, weights=self.weights, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) - -class NeighborsFunctionalRegressorMixin: - """Mixin class for the functional regressors based in neighbors""" - - def fit(self, X, y): - """Fit the model using X as training data. - - Args: - X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid - with the training data or array matrix with shape - [n_samples, n_samples] if metric='precomputed'. - - - """ - if (X.nsamples != y.nsamples): - raise ValueError("The response and dependent variable must " - "contain the same number of samples,") - - # If metric is precomputed no different with the Sklearn stimator - if self.metric == 'precomputed': - self.estimator_ = self._init_estimator(self.metric) - self.estimator_.fit(X) - else: - self._sample_points = X.sample_points - self._shape = X.data_matrix.shape[1:] - - if not self.sklearn_metric: - # Constructs sklearn metric to manage vector instead of grids - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) - else: - sk_metric = self.metric - - self.estimator_ = self._init_estimator(sk_metric) - self.estimator_.fit(self._transform_to_multivariate(X)) - - # Choose proper local regressor - if self.weights == 'uniform': - self.local_regressor = self._uniform_local_regression - elif self.weight == 'distance': - self.local_regressor = self._distance_local_regression - else: - self.local_regressor = self._weighted_local_regression - - # Store the responses - self._y = y - - return self - - def _uniform_local_regression(self, neighbors, distance=None): - """Perform local regression with uniform weights""" - return self.regressor(neighbors) - - def _distance_local_regression(self, neighbors, distance): - """Perform local regression using distances as weights""" - idx = distance == 0. - if np.any(idx): - weights = distance - weights[idx] = 1. / np.sum(idx) - weights[~idx] = 0. - else: - weights = 1. / distance - weights /= np.sum(weights) - - return self.regressor(neighbors, weights) - - - def _weighted_local_regression(self, neighbors, distance): - """Perform local regression using custom weights""" - - weights = self.weights(distance) - - return self.regressor(neighbors, weights) - - def predict(self, X): - """Predict functional responses. - - Args: - X (:class:`FDataGrid` or array-like): FDataGrid with the test - samples or array (n_query, n_indexed) if metric == - 'precomputed'. - - Returns - - y : :class:`FDataGrid` containing as many samples as X. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - distances, neighbors = self._query(X) - - - # Todo: change the concatenation after merge image-operations branch - if len(neighbors[0]) == 0: - pred = self._outlier_response(neighbors) - else: - pred = self.local_regressor(self._y[neighbors[0]], distances[0]) - - for i, idx in enumerate(neighbors[1:]): - if len(idx) == 0: - new_pred = self._outlier_response(neighbors) - else: - new_pred = self.local_regressor(self._y[idx], distances[i+1]) - - pred = pred.concatenate(new_pred) - - return pred - - def _outlier_response(self, neighbors): - """Response in case of no neighbors""" - - if (not hasattr(self, "outlier_response") or - self.outlier_response is None): - index = np.where([len(n)==0 for n in neighbors])[0] - - raise ValueError(f"No neighbors found for test samples {index}, " - "you can try using larger radius, give a reponse " - "for outliers, or consider removing them from your" - " dataset.") - else: - return self.outlier_response - - - @abstractmethod - def _query(self): - """Return distances and neighbors of given sample""" - pass - - def score(self, X, y): - """TODO""" - - # something like - # pred = self.pred(X) - # return score(pred, y) - # - raise NotImplementedError - -class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, - NeighborsBase, KNeighborsMixin, - NeighborsFunctionalRegressorMixin): - """Functional regression based on neighbors within a fixed radius. - - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. - - Parameters - ---------- - n_neighbors : int, optional (default = 5) - Number of neighbors to use by default for :meth:`kneighbors` queries. - weights : str or callable - weight function used in prediction. Possible values: - - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. - - Uniform weights are used by default. - regressor : callable, optional ((default = - :func:`mean `)) - Function to perform the local regression. By default used the mean. Can - accept a user-defined function wich accepts a :class:`FDataGrid` with - the neighbors of a test sample, and if weights != 'uniform' an array - of weights as second parameter. - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm - based on the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with gaussian-like samples shifted, - and we will try to predict 5 X +1. - - >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) - >>> y_train = 5 * X_train + 1 - >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) - - We will fit a K-Nearest Neighbors functional regressor. - - >>> from skfda.ml.regression import KNeighborsFunctionalRegressor - >>> neigh = KNeighborsFunctionalRegressor() - >>> neigh.fit(X_train, y_train) - KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) - - We can predict the response of new samples. - - >>> neigh.predict(X_test) - FDataGrid(...) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.RadiusNeighborsClassifier`. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - - - def __init__(self, n_neighbors=5, weights='uniform', regressor=mean, - algorithm='auto', leaf_size=30, metric=lp_distance, - metric_params=None, n_jobs=1, sklearn_metric=False): - """Initialize the classifier.""" - - super().__init__(n_neighbors=n_neighbors, radius=1., - weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - self.regressor = regressor - - def _query(self, X): - """Return distances and neighbors of given sample""" - return self.estimator_.kneighbors(X) - -class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, - NeighborsBase, RadiusNeighborsMixin, - NeighborsFunctionalRegressorMixin): - """Functional regression based on neighbors within a fixed radius. - - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. - - Parameters - ---------- - radius : float, optional (default = 1.0) - Range of parameter space to use by default for :meth:`radius_neighbors` - queries. - weights : str or callable - weight function used in prediction. Possible values: - - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. - - Uniform weights are used by default. - regressor : callable, optional ((default = - :func:`mean `)) - Function to perform the local regression. By default used the mean. Can - accept a user-defined function wich accepts a :class:`FDataGrid` with - the neighbors of a test sample, and if weights != 'uniform' an array - of weights as second parameter. - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm - based on the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - outlier_response : :class:`FDataGrid`, optional (default = None) - Default response for test samples without neighbors. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with gaussian-like samples shifted, - and we will try to predict 5 X +1. - - >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) - >>> y_train = 5 * X_train + 1 - >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) - - We will fit a Radius Nearest Neighbors functional regressor. - - >>> from skfda.ml.regression import RadiusNeighborsFunctionalRegressor - >>> neigh = RadiusNeighborsFunctionalRegressor(radius=.03) - >>> neigh.fit(X_train, y_train) - RadiusNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) - - We can predict the response of new samples. - - >>> neigh.predict(X_test) - FDataGrid(...) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.RadiusNeighborsClassifier`. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - - def __init__(self, radius=1., weights='uniform', regressor=mean, - algorithm='auto', leaf_size=30, metric=lp_distance, - metric_params=None, outlier_response=None, n_jobs=1, - sklearn_metric=False): - """Initialize the classifier.""" - - super().__init__(n_neighbors=5, radius=radius, - weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - self.regressor = regressor - self.outlier_response = outlier_response - - def _query(self, X): - """Return distances and neighbors of given sample""" - return self.estimator_.radius_neighbors(X) diff --git a/skfda/ml/classification/__init__.py b/skfda/ml/classification/__init__.py index 76863d9bb..923ebe7c1 100644 --- a/skfda/ml/classification/__init__.py +++ b/skfda/ml/classification/__init__.py @@ -1,4 +1,4 @@ -from .._neighbors import (KNeighborsClassifier, RadiusNeighborsClassifier, - NearestNeighbors, NearestCentroids) +from ..._neighbors import (KNeighborsClassifier, RadiusNeighborsClassifier, + NearestNeighbors, NearestCentroids) diff --git a/skfda/ml/regression/__init__.py b/skfda/ml/regression/__init__.py index 21629b7f9..00f57c771 100644 --- a/skfda/ml/regression/__init__.py +++ b/skfda/ml/regression/__init__.py @@ -1,9 +1,8 @@ -from .._neighbors import (KNeighborsScalarRegressor, - RadiusNeighborsScalarRegressor, - KNeighborsFunctionalRegressor, - RadiusNeighborsFunctionalRegressor) +from ..._neighbors import (KNeighborsScalarRegressor, + RadiusNeighborsScalarRegressor, + KNeighborsFunctionalRegressor, + RadiusNeighborsFunctionalRegressor) from .linear_model import LinearScalarRegression - diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 35e13032f..c4ca4614e 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -534,8 +534,8 @@ def mean(self, weights=None): """ if weights is not None: - return self.copy(data_matrix=numpy.average( - self.data_matrix, weights=weights, axis=0)[numpy.newaxis,...] + return self.copy(data_matrix=np.average( + self.data_matrix, weights=weights, axis=0)[np.newaxis,...] ) return self.copy(data_matrix=self.data_matrix.mean(axis=0, diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 158da120a..f3fd6e44c 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -11,8 +11,12 @@ NearestNeighbors) from skfda.ml.regression import (KNeighborsScalarRegressor, - RadiusNeighborsScalarRegressor) + RadiusNeighborsScalarRegressor, + KNeighborsFunctionalRegressor, + RadiusNeighborsFunctionalRegressor) + from skfda.misc.metrics import lp_distance, lp_distance +from skfda.representation.basis import Fourier class TestNeighbors(unittest.TestCase): @@ -34,6 +38,10 @@ def setUp(self): modes_location=modes_location, noise=.05, random_state=random_state) + self.X2 = make_multimodal_samples(n_samples=30, + modes_location=modes_location, + noise=.05, + random_state=1) self.probs = np.array(15*[[1., 0.]] + 15*[[0., 1.]])[idx] @@ -137,6 +145,86 @@ def test_radius_neighbors(self): self.assertEqual(graph[0, i] == 1.0, i in links[0]) self.assertEqual(graph[0, i] == 0.0, i not in links[0]) + def test_knn_functional_response(self): + knnr = KNeighborsFunctionalRegressor(n_neighbors=1) + + knnr.fit(self.X, self.X) + + res = knnr.predict(self.X) + np.testing.assert_array_almost_equal(res.data_matrix, + self.X.data_matrix) + + def test_radius_functional_response(self): + knnr = RadiusNeighborsFunctionalRegressor(weights='distance') + + knnr.fit(self.X, self.X) + + res = knnr.predict(self.X) + np.testing.assert_array_almost_equal(res.data_matrix, + self.X.data_matrix) + + def test_functional_response_custom_weights(self): + + def weights(weights): + + return np.array([w == 0 for w in weights], dtype=float) + + knnr = KNeighborsFunctionalRegressor(weights=weights, n_neighbors=5) + response = self.X.to_basis(Fourier(domain_range=(-1, 1), nbasis=10)) + knnr.fit(self.X, response) + + res = knnr.predict(self.X) + np.testing.assert_array_almost_equal(res.coefficients, + response.coefficients) + + def test_functional_response_basis(self): + knnr = KNeighborsFunctionalRegressor(weights='distance', n_neighbors=5) + response = self.X.to_basis(Fourier(domain_range=(-1, 1), nbasis=10)) + knnr.fit(self.X, response) + + res = knnr.predict(self.X) + np.testing.assert_array_almost_equal(res.coefficients, + response.coefficients) + + def test_radius_outlier_functional_response(self): + knnr = RadiusNeighborsFunctionalRegressor(radius=0.001) + knnr.fit(self.X[3:6], self.X[3:6]) + + # No value given + with np.testing.assert_raises(ValueError): + knnr.predict(self.X[:10]) + + # Test response + knnr = RadiusNeighborsFunctionalRegressor(radius=0.001, + outlier_response=self.X[0]) + knnr.fit(self.X[3:6], self.X[3:6]) + + res = knnr.predict(self.X[0]) + np.testing.assert_array_almost_equal(self.X[0].data_matrix, + res.data_matrix) + + def test_nearest_centroids_exceptions(self): + + # Test more than one class + nn = NearestCentroids() + with np.testing.assert_raises(ValueError): + nn.fit(self.X[0:3], 3*[0]) + + # Precomputed not supported + nn = NearestCentroids(metric='precomputed') + with np.testing.assert_raises(ValueError): + nn.fit(self.X[0:3], 3*[0]) + + def test_functional_regressor_exceptions(self): + + knnr = RadiusNeighborsFunctionalRegressor() + + with np.testing.assert_raises(ValueError): + knnr.fit(self.X[:3], self.X[:4]) + + + + if __name__ == '__main__': print() unittest.main() From 385f869d8b92f8c170f272c61de80bd524cca02f Mon Sep 17 00:00:00 2001 From: pablomm Date: Fri, 26 Jul 2019 19:28:45 +0200 Subject: [PATCH 153/222] doctest --- skfda/_neighbors/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 5031a631e..c493ab72d 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -66,7 +66,7 @@ def _to_sklearn_metric(metric, sample_points): >>> import numpy as np >>> from skfda import FDataGrid >>> from skfda.misc.metrics import lp_distance - >>> from skfda.ml._neighbors import _to_sklearn_metric + >>> from skfda._neighbors.base import _to_sklearn_metric Calculate the Lp distance between fd and fd2. From 5e0611c3a4c7e44f2497d813e8276b71c46fafc5 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 27 Jul 2019 01:33:29 +0200 Subject: [PATCH 154/222] Tests --- skfda/_neighbors/base.py | 33 +++++----- skfda/_neighbors/classification.py | 2 + skfda/_neighbors/regression.py | 9 ++- skfda/_neighbors/unsupervised.py | 31 +--------- tests/test_neighbors.py | 98 ++++++++++++++++++++++++------ 5 files changed, 101 insertions(+), 72 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index c493ab72d..61e16bee1 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -138,19 +138,11 @@ def _transform_to_multivariate(self, X): return X - def _transform_from_multivariate(self, X): - """Transform from array like to FDatagrid.""" - - if X.ndim == 1: - shape = (1, ) + self._shape - else: - shape = (len(X), ) + self._shape - - return _from_multivariate(X, self._sample_points, shape) class NeighborsMixin: """Mixin class to train the neighbors models""" - def fit(self, X, y): + + def fit(self, X, y=None): """Fit the model using X as training data and y as target values. Args: @@ -159,6 +151,7 @@ def fit(self, X, y): [n_samples, n_samples] if metric='precomputed'. y (array-like or sparse matrix): Target values of shape = [n_samples] or [n_samples, n_outputs]. + In the case of unsupervised search, this parameter is ignored. Note: This method wraps the corresponding sklearn routine in the module @@ -175,7 +168,8 @@ def fit(self, X, y): if not self.sklearn_metric: # Constructs sklearn metric to manage vector - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + sk_metric = _to_sklearn_metric( + self.metric, self._sample_points) else: sk_metric = self.metric @@ -441,6 +435,7 @@ def predict(self, X): return self.estimator_.predict(X) + class NeighborsScalarRegresorMixin: """Mixin class for scalar regressor based in nearest neighbors""" @@ -467,6 +462,7 @@ def predict(self, X): return self.estimator_.predict(X) + class NearestNeighborsMixinInit: def _init_estimator(self, sk_metric): """Initialize the sklearn nearest neighbors estimator. @@ -486,6 +482,7 @@ def _init_estimator(self, sk_metric): metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) + class NeighborsFunctionalRegressorMixin: """Mixin class for the functional regressors based in neighbors""" @@ -499,7 +496,7 @@ def fit(self, X, y): """ - if X.nsamples != y.nsamples: + if len(X) != y.nsamples: raise ValueError("The response and dependent variable must " "contain the same number of samples,") @@ -513,7 +510,8 @@ def fit(self, X, y): if not self.sklearn_metric: # Constructs sklearn metric to manage vector instead of grids - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) + sk_metric = _to_sklearn_metric( + self.metric, self._sample_points) else: sk_metric = self.metric @@ -550,7 +548,6 @@ def _distance_local_regression(self, neighbors, distance): return self.regressor(neighbors, weights) - def _weighted_local_regression(self, neighbors, distance): """Perform local regression using custom weights""" @@ -577,7 +574,6 @@ def predict(self, X): distances, neighbors = self._query(X) - # Todo: change the concatenation after merge image-operations branch if len(neighbors[0]) == 0: pred = self._outlier_response(neighbors) @@ -588,7 +584,7 @@ def predict(self, X): if len(idx) == 0: new_pred = self._outlier_response(neighbors) else: - new_pred = self.local_regressor(self._y[idx], distances[i+1]) + new_pred = self.local_regressor(self._y[idx], distances[i + 1]) pred = pred.concatenate(new_pred) @@ -598,8 +594,8 @@ def _outlier_response(self, neighbors): """Response in case of no neighbors""" if (not hasattr(self, "outlier_response") or - self.outlier_response is None): - index = np.where([len(n)==0 for n in neighbors])[0] + self.outlier_response is None): + index = np.where([len(n) == 0 for n in neighbors])[0] raise ValueError(f"No neighbors found for test samples {index}, " "you can try using larger radius, give a reponse " @@ -608,7 +604,6 @@ def _outlier_response(self, neighbors): else: return self.outlier_response - @abstractmethod def _query(self): """Return distances and neighbors of given sample""" diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 257c600bc..3169c6fd3 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -13,6 +13,7 @@ from ..misc.metrics import lp_distance, pairwise_distance from ..exploratory.stats import mean + class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, ClassifierMixin, NeighborsClassifierMixin): """Classifier implementing the k-nearest neighbors vote. @@ -352,6 +353,7 @@ class NearestCentroids(BaseEstimator, ClassifierMixin): NearestNeighbors """ + def __init__(self, metric=lp_distance, mean=mean): """Initialize the classifier.""" self.metric = metric diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index dc64c97dd..1c5af2c04 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -14,6 +14,7 @@ from ..exploratory.stats import mean from ..misc.metrics import lp_distance + class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, KNeighborsMixin, RegressorMixin, NeighborsScalarRegresorMixin): @@ -113,6 +114,7 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm """ + def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', leaf_size=30, metric=lp_distance, metric_params=None, n_jobs=1, sklearn_metric=False): @@ -142,6 +144,7 @@ def _init_estimator(self, sk_metric): metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) + class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, RadiusNeighborsMixin, RegressorMixin, NeighborsScalarRegresorMixin): @@ -248,7 +251,6 @@ def __init__(self, radius=1.0, weights='uniform', algorithm='auto', metric_params=metric_params, n_jobs=n_jobs, sklearn_metric=sklearn_metric) - def _init_estimator(self, sk_metric): """Initialize the sklearn radius neighbors estimator. @@ -267,6 +269,7 @@ def _init_estimator(self, sk_metric): metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) + class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, NeighborsBase, KNeighborsMixin, NeighborsFunctionalRegressorMixin): @@ -367,10 +370,9 @@ class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, """ - def __init__(self, n_neighbors=5, weights='uniform', regressor=mean, algorithm='auto', leaf_size=30, metric=lp_distance, - metric_params=None, n_jobs=1, sklearn_metric=False): + metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" super().__init__(n_neighbors=n_neighbors, radius=1., @@ -384,6 +386,7 @@ def _query(self, X): """Return distances and neighbors of given sample""" return self.estimator_.kneighbors(X) + class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, NeighborsBase, RadiusNeighborsMixin, NeighborsFunctionalRegressorMixin): diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index 1a28b4946..e751b4634 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -4,6 +4,7 @@ from ..misc.metrics import lp_distance + class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, KNeighborsMixin, RadiusNeighborsMixin): """Unsupervised learner for implementing neighbor searches. @@ -108,33 +109,3 @@ def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, sklearn_metric=sklearn_metric) - - def fit(self, X, y=None): - """Fit the model using X as training data. - - Args: - X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid - with the training data or array matrix with shape - [n_samples, n_samples] if metric='precomputed'. - y (None) : Parameter ignored. - - Note: - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - # If metric is precomputed no different with the Sklearn stimator - if self.metric == 'precomputed': - self.estimator_ = self._init_estimator(self.metric) - self.estimator_.fit(X) - else: - self._sample_points = X.sample_points - self._shape = X.data_matrix.shape[1:] - - # Constructs sklearn metric to manage vector instead of FDatagrids - sk_metric = _to_sklearn_metric(self.metric, self._sample_points) - - self.estimator_ = self._init_estimator(sk_metric) - self.estimator_.fit(self._transform_to_multivariate(X)) - - return self diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index f3fd6e44c..407df6a16 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -15,9 +15,10 @@ KNeighborsFunctionalRegressor, RadiusNeighborsFunctionalRegressor) -from skfda.misc.metrics import lp_distance, lp_distance +from skfda.misc.metrics import lp_distance, pairwise_distance from skfda.representation.basis import Fourier + class TestNeighbors(unittest.TestCase): def setUp(self): @@ -29,21 +30,20 @@ def setUp(self): idx = np.arange(30) random_state.shuffle(idx) - modes_location = modes_location[idx] self.modes_location = modes_location - self.y = np.array(15*[0] + 15*[1])[idx] + self.y = np.array(15 * [0] + 15 * [1])[idx] self.X = make_multimodal_samples(n_samples=30, modes_location=modes_location, noise=.05, random_state=random_state) self.X2 = make_multimodal_samples(n_samples=30, - modes_location=modes_location, - noise=.05, - random_state=1) + modes_location=modes_location, + noise=.05, + random_state=1) - self.probs = np.array(15*[[1., 0.]] + 15*[[0., 1.]])[idx] + self.probs = np.array(15 * [[1., 0.]] + 15 * [[0., 1.]])[idx] def test_predict_classifier(self): """Tests predict for neighbors classifier""" @@ -70,12 +70,11 @@ def test_predict_proba_classifier(self): def test_predict_regressor(self): """Test scalar regression, predics mode location""" - #Dummy test, with weight = distance, only the sample with distance 0 + # Dummy test, with weight = distance, only the sample with distance 0 # will be returned, obtaining the exact location knnr = KNeighborsScalarRegressor(weights='distance') rnnr = RadiusNeighborsScalarRegressor(weights='distance', radius=.1) - knnr.fit(self.X, self.modes_location) rnnr.fit(self.X, self.modes_location) @@ -84,7 +83,6 @@ def test_predict_regressor(self): np.testing.assert_array_almost_equal(rnnr.predict(self.X), self.modes_location) - def test_kneighbors(self): nn = NearestNeighbors() @@ -100,14 +98,14 @@ def test_kneighbors(self): dist, links = neigh.kneighbors(self.X[:4]) - np.testing.assert_array_equal(links, [[ 0, 7, 21, 23, 15], - [ 1, 12, 19, 18, 17], - [ 2, 17, 22, 27, 26], - [ 3, 4, 9, 5, 25]]) + np.testing.assert_array_equal(links, [[0, 7, 21, 23, 15], + [1, 12, 19, 18, 17], + [2, 17, 22, 27, 26], + [3, 4, 9, 5, 25]]) dist_kneigh = lp_distance(self.X[0], self.X[7]) - np.testing.assert_array_almost_equal(dist[0,1], dist_kneigh) + np.testing.assert_array_almost_equal(dist[0, 1], dist_kneigh) graph = neigh.kneighbors_graph(self.X[:4]) @@ -132,7 +130,7 @@ def test_radius_neighbors(self): np.testing.assert_array_equal(links[0], np.array([0, 7])) np.testing.assert_array_equal(links[1], np.array([1])) - np.testing.assert_array_equal(links[2], np.array([ 2, 17, 22, 27])) + np.testing.assert_array_equal(links[2], np.array([2, 17, 22, 27])) np.testing.assert_array_equal(links[3], np.array([3, 4, 9])) dist_kneigh = lp_distance(self.X[0], self.X[7]) @@ -154,6 +152,28 @@ def test_knn_functional_response(self): np.testing.assert_array_almost_equal(res.data_matrix, self.X.data_matrix) + def test_knn_functional_response_sklearn(self): + # Check sklearn metric + knnr = KNeighborsFunctionalRegressor(n_neighbors=1, metric='euclidean', + sklearn_metric=True) + knnr.fit(self.X, self.X) + + res = knnr.predict(self.X) + np.testing.assert_array_almost_equal(res.data_matrix, + self.X.data_matrix) + + def test_knn_functional_response_precomputed(self): + knnr = KNeighborsFunctionalRegressor(n_neighbors=4, weights='distance', + metric='precomputed') + d = pairwise_distance(lp_distance) + distances = d(self.X[:4], self.X[:4]) + + knnr.fit(distances, self.X[:4]) + + res = knnr.predict(distances) + np.testing.assert_array_almost_equal(res.data_matrix, + self.X[:4].data_matrix) + def test_radius_functional_response(self): knnr = RadiusNeighborsFunctionalRegressor(weights='distance') @@ -177,6 +197,23 @@ def weights(weights): np.testing.assert_array_almost_equal(res.coefficients, response.coefficients) + def test_functional_regression_distance_weights(self): + + knnr = KNeighborsFunctionalRegressor( + weights='distance', n_neighbors=10) + knnr.fit(self.X[:10], self.X[:10]) + res = knnr.predict(self.X[11]) + + d = pairwise_distance(lp_distance) + distances = d(self.X[:10], self.X[11]).flatten() + + weights = 1 / distances + weights /= weights.sum() + + response = self.X[:10].mean(weights=weights) + np.testing.assert_array_almost_equal(res.data_matrix, + response.data_matrix) + def test_functional_response_basis(self): knnr = KNeighborsFunctionalRegressor(weights='distance', n_neighbors=5) response = self.X.to_basis(Fourier(domain_range=(-1, 1), nbasis=10)) @@ -197,9 +234,9 @@ def test_radius_outlier_functional_response(self): # Test response knnr = RadiusNeighborsFunctionalRegressor(radius=0.001, outlier_response=self.X[0]) - knnr.fit(self.X[3:6], self.X[3:6]) + knnr.fit(self.X[:6], self.X[:6]) - res = knnr.predict(self.X[0]) + res = knnr.predict(self.X[7]) np.testing.assert_array_almost_equal(self.X[0].data_matrix, res.data_matrix) @@ -208,12 +245,12 @@ def test_nearest_centroids_exceptions(self): # Test more than one class nn = NearestCentroids() with np.testing.assert_raises(ValueError): - nn.fit(self.X[0:3], 3*[0]) + nn.fit(self.X[0:3], 3 * [0]) # Precomputed not supported nn = NearestCentroids(metric='precomputed') with np.testing.assert_raises(ValueError): - nn.fit(self.X[0:3], 3*[0]) + nn.fit(self.X[0:3], 3 * [0]) def test_functional_regressor_exceptions(self): @@ -222,7 +259,28 @@ def test_functional_regressor_exceptions(self): with np.testing.assert_raises(ValueError): knnr.fit(self.X[:3], self.X[:4]) + def test_search_neighbors_precomputed(self): + d = pairwise_distance(lp_distance) + distances = d(self.X[:4], self.X[:4]) + + nn = NearestNeighbors(metric='precomputed', n_neighbors=2) + nn.fit(distances, self.y[:4]) + + _, neighbors = nn.kneighbors(distances) + + result = np.array([[0, 3], [1, 2], [2, 1], [3, 0]]) + np.testing.assert_array_almost_equal(neighbors, result) + + def test_search_neighbors_sklearn(self): + + nn = NearestNeighbors(metric='euclidean', sklearn_metric=True, + n_neighbors=2) + nn.fit(self.X[:4], self.y[:4]) + + _, neighbors = nn.kneighbors(self.X[:4]) + result = np.array([[0, 3], [1, 2], [2, 1], [3, 0]]) + np.testing.assert_array_almost_equal(neighbors, result) if __name__ == '__main__': From 150bc41b22373cfb0d37f6d1b745a24904829efd Mon Sep 17 00:00:00 2001 From: pablomm Date: Sun, 28 Jul 2019 15:04:43 +0200 Subject: [PATCH 155/222] Score functional response --- skfda/_neighbors/base.py | 118 +++++++++++++++++++++-------- skfda/_neighbors/classification.py | 25 +++--- skfda/_neighbors/regression.py | 34 ++++----- skfda/_neighbors/unsupervised.py | 8 +- tests/test_neighbors.py | 36 ++++++++- 5 files changed, 152 insertions(+), 69 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 61e16bee1..6048ee7cc 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -6,6 +6,7 @@ from sklearn.base import BaseEstimator from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted from sklearn.neighbors import NearestNeighbors as _NearestNeighbors +import scipy.integrate from .. import FDataGrid from ..misc.metrics import lp_distance @@ -114,11 +115,6 @@ def __init__(self, n_neighbors=None, radius=None, self.n_jobs = n_jobs self.sklearn_metric = sklearn_metric - @abstractmethod - def _init_estimator(self, sk_metric): - """Initializes the estimator returned by :meth:`_sklearn_neighbors`.""" - pass - def _check_is_fitted(self): """Check if the estimator is fitted. @@ -167,7 +163,7 @@ def fit(self, X, y=None): self._shape = X.data_matrix.shape[1:] if not self.sklearn_metric: - # Constructs sklearn metric to manage vector + # Constructs sklearn metric to manage vector sk_metric = _to_sklearn_metric( self.metric, self._sample_points) else: @@ -187,10 +183,11 @@ def kneighbors(self, X=None, n_neighbors=None, return_distance=True): Returns indices of and distances to the neighbors of each point. Args: - X (:class:`FDataGrid` or matrix): FDatagrid with the query functions - or matrix (n_query, n_indexed) if metric == 'precomputed'. If - not provided, neighbors of each indexed point are returned. In - this case, the query point is not considered its own neighbor. + X (:class:`FDataGrid` or matrix): FDatagrid with the query + functions or matrix (n_query, n_indexed) if + metric == 'precomputed'. If not provided, neighbors of each + indexed point are returned. In this case, the query point is + not considered its own neighbor. n_neighbors (int): Number of neighbors to get (default is the value passed to the constructor). return_distance (boolean, optional): Defaults to True. If False, @@ -243,10 +240,11 @@ def kneighbors_graph(self, X=None, n_neighbors=None, mode='connectivity'): """Computes the (weighted) graph of k-Neighbors for points in X Args: - X (:class:`FDataGrid` or matrix): FDatagrid with the query functions - or matrix (n_query, n_indexed) if metric == 'precomputed'. If - not provided, neighbors of each indexed point are returned. In - this case, the query point is not considered its own neighbor. + X (:class:`FDataGrid` or matrix): FDatagrid with the query + functions or matrix (n_query, n_indexed) if + metric == 'precomputed'. If not provided, neighbors of each + indexed point are returned. In this case, the query point is + not considered its own neighbor. n_neighbors (int): Number of neighbors to get (default is the value passed to the constructor). mode ('connectivity' or 'distance', optional): Type of returned @@ -373,8 +371,8 @@ def radius_neighbors(self, X=None, radius=None, return_distance=True): X = self._transform_to_multivariate(X) - return self.estimator_.radius_neighbors(X=X, radius=radius, - return_distance=return_distance) + return self.estimator_.radius_neighbors( + X=X, radius=radius, return_distance=return_distance) def radius_neighbors_graph(self, X=None, radius=None, mode='connectivity'): """Computes the (weighted) graph of Neighbors for points in X @@ -518,7 +516,7 @@ def fit(self, X, y): self.estimator_ = self._init_estimator(sk_metric) self.estimator_.fit(self._transform_to_multivariate(X)) - # Choose proper local regressor + # Choose proper local regressor if self.weights == 'uniform': self.local_regressor = self._uniform_local_regression elif self.weights == 'distance': @@ -574,7 +572,6 @@ def predict(self, X): distances, neighbors = self._query(X) - # Todo: change the concatenation after merge image-operations branch if len(neighbors[0]) == 0: pred = self._outlier_response(neighbors) else: @@ -599,21 +596,76 @@ def _outlier_response(self, neighbors): raise ValueError(f"No neighbors found for test samples {index}, " "you can try using larger radius, give a reponse " - "for outliers, or consider removing them from your" - " dataset.") + "for outliers, or consider removing them from " + "your dataset.") else: return self.outlier_response - @abstractmethod - def _query(self): - """Return distances and neighbors of given sample""" - pass - - def score(self, X, y): - """TODO""" - - # something like - # pred = self.pred(X) - # return score(pred, y) - # - raise NotImplementedError + def score(self, X, y, sample_weight=None): + r"""Return an extension of the coefficient of determination R^2. + + The coefficient is defined as + + .. math:: + 1 - \frac{\sum_{i=1}^{n}\int (y_i(t) - \hat{y}_i(t))^2dt} + {\sum_{i=1}^{n} \int (y_i(t) - \frac{1}{n}\sum_{i=1}^{n}y_i(t))^2dt} + + where :math:`\hat{y}_i` is the prediction associated to the test sample + :math:`X_i`, and :math:`{y}_i` is the true response. + + The best possible score is 1.0 and it can be negative + (because the model can be arbitrarily worse). A constant model that + always predicts the expected value of y, disregarding the input + features, would get a R^2 score of 0.0. + + Args: + X (FDataGrid): Test samples to be predicted. + y (FData): True responses of the test samples. + sample_weight (array_like, shape = [n_samples], optional): Sample + weights. + + Returns: + (float): Coefficient of determination. + + """ + if y.ndim_image != 1 or y.ndim_domain != 1: + raise ValueError("Score not implemented for multivariate " + "functional data.") + + # Make prediction + pred = self.predict(X) + + u = y - pred + v = y - y.mean() + + # Discretize to integrate and make squares if needed + if type(u) != FDataGrid: + u = u.to_grid() + v = v.to_grid() + + data_u = u.data_matrix[..., 0] + data_v = v.data_matrix[..., 0] + + # Square without allocate more memory + np.square(data_u, out=data_u) + np.square(data_v, out=data_v) + + if sample_weight is not None: + if len(sample_weight) != len(y): + raise ValueError("Must be a weight for each sample.") + + sample_weight = np.asarray(sample_weight) + sample_weight = sample_weight / sample_weight.sum() + data_u_t = data_u.T + data_u_t *= sample_weight + data_v_t = data_v.T + data_v_t *= sample_weight + + # Sum and integrate + sum_u = np.sum(data_u, axis=0) + sum_v = np.sum(data_v, axis=0) + + int_u = scipy.integrate.simps(sum_u, x=u.sample_points[0]) + int_v = scipy.integrate.simps(sum_v, x=v.sample_points[0]) + + return 1 - int_u / int_v diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 3169c6fd3..4d871e0f3 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -1,5 +1,6 @@ -from .base import NeighborsBase, NeighborsMixin, KNeighborsMixin, NeighborsClassifierMixin, RadiusNeighborsMixin +from .base import (NeighborsBase, NeighborsMixin, KNeighborsMixin, + NeighborsClassifierMixin, RadiusNeighborsMixin) from sklearn.utils.multiclass import check_classification_targets from sklearn.preprocessing import LabelEncoder @@ -61,8 +62,8 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, Doesn't affect :meth:`fit` method. sklearn_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of + the module :mod:`skfda.misc.metrics`. Examples -------- Firstly, we will create a toy dataset with 2 classes @@ -219,8 +220,8 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, ``-1`` means using all processors. sklearn_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of + the module :mod:`skfda.misc.metrics`. Examples -------- Firstly, we will create a toy dataset with 2 classes. @@ -305,18 +306,18 @@ class NearestCentroids(BaseEstimator, ClassifierMixin): ---------- metric : callable, (default :func:`lp_distance `) - The metric to use when calculating distance between test samples and - centroids. See the documentation of the metrics module + The metric to use when calculating distance between test samples + and centroids. See the documentation of the metrics module for a list of available metrics. Defaults used L2 distance. mean: callable, (default :func:`mean `) The centroids for the samples corresponding to each class is the point from which the sum of the distances (according to the metric) of all samples that belong to that particular class are minimized. By default it is used the usual mean, which minimizes the sum of L2 - distance. This parameter allows change the centroid constructor. The - function must accept a :class:`FData` with the samples of one class - and return a :class:`FData` object with only one sample representing - the centroid. + distance. This parameter allows change the centroid constructor. + The function must accept a :class:`FData` with the samples of one + class and return a :class:`FData` object with only one sample + representing the centroid. Attributes ---------- centroids_ : :class:`FDataGrid` @@ -387,8 +388,6 @@ def fit(self, X, y): self.centroids_ = self.mean(X[y_ind == 0]) - # This could be changed to allow all the concatenation at the same time - # After merge image-operations for cur_class in range(1, n_classes): center_mask = y_ind == cur_class centroid = self.mean(X[center_mask]) diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index 1c5af2c04..f3910f1bb 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -20,8 +20,8 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, NeighborsScalarRegresorMixin): """Regression based on k-nearest neighbors with scalar response. - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. + The target is predicted by local interpolation of the targets associated of + the nearest neighbors in the training set. Parameters ---------- @@ -66,8 +66,8 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, Doesn't affect :meth:`fit` method. sklearn_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of + the module :mod:`skfda.misc.metrics`. Examples -------- Firstly, we will create a toy dataset with gaussian-like samples shifted. @@ -150,8 +150,8 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, NeighborsScalarRegresorMixin): """Scalar regression based on neighbors within a fixed radius. - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. + The target is predicted by local interpolation of the targets associated of + the nearest neighbors in the training set. Parameters ---------- @@ -197,8 +197,8 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, ``-1`` means using all processors. sklearn_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of + the module :mod:`skfda.misc.metrics`. Examples -------- Firstly, we will create a toy dataset with gaussian-like samples shifted. @@ -327,17 +327,17 @@ class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, ``-1`` means using all processors. sklearn_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of + the module :mod:`skfda.misc.metrics`. Examples -------- Firstly, we will create a toy dataset with gaussian-like samples shifted, and we will try to predict 5 X +1. >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> X_train = make_multimodal_samples(n_samples=30, std=.05, random_state=0) >>> y_train = 5 * X_train + 1 - >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) + >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) We will fit a K-Nearest Neighbors functional regressor. @@ -447,17 +447,17 @@ class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, ``-1`` means using all processors. sklearn_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of + the module :mod:`skfda.misc.metrics`. Examples -------- Firstly, we will create a toy dataset with gaussian-like samples shifted, - and we will try to predict 5 X +1. + and we will try to predict the response 5 X +1. >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> X_train = make_multimodal_samples(n_samples=30, std=.05, random_state=0) >>> y_train = 5 * X_train + 1 - >>> X_test = make_multimodal_samples(n_samples=5, std=.5, random_state=0) + >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) We will fit a Radius Nearest Neighbors functional regressor. diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index e751b4634..f1995de44 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -5,8 +5,8 @@ from ..misc.metrics import lp_distance -class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, - KNeighborsMixin, RadiusNeighborsMixin): +class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, + NeighborsMixin, KNeighborsMixin, RadiusNeighborsMixin): """Unsupervised learner for implementing neighbor searches. Parameters @@ -43,8 +43,8 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, Doesn't affect :meth:`fit` method. sklearn_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the - module :mod:`skfda.misc.metrics`. + :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of + the module :mod:`skfda.misc.metrics`. Examples -------- Firstly, we will create a toy dataset with 2 classes diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 407df6a16..5afaa3edd 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -236,9 +236,9 @@ def test_radius_outlier_functional_response(self): outlier_response=self.X[0]) knnr.fit(self.X[:6], self.X[:6]) - res = knnr.predict(self.X[7]) + res = knnr.predict(self.X[:7]) np.testing.assert_array_almost_equal(self.X[0].data_matrix, - res.data_matrix) + res[6].data_matrix) def test_nearest_centroids_exceptions(self): @@ -282,6 +282,38 @@ def test_search_neighbors_sklearn(self): result = np.array([[0, 3], [1, 2], [2, 1], [3, 0]]) np.testing.assert_array_almost_equal(neighbors, result) + def test_score_functional_response(self): + + neigh = KNeighborsFunctionalRegressor() + + y = 5 * self.X + 1 + neigh.fit(self.X, y) + r = neigh.score(self.X, y) + np.testing.assert_almost_equal(r,0.962651178452408) + + #Weighted case and basis form + y = y.to_basis(Fourier(domain_range=y.domain_range[0], nbasis=5)) + neigh.fit(self.X, y) + + r = neigh.score(self.X[:7], y[:7], sample_weight=4*[1./5]+ 3 *[1./15]) + np.testing.assert_almost_equal(r, 0.9982527586114364) + + def test_score_functional_response_exceptions(self): + neigh = RadiusNeighborsFunctionalRegressor() + neigh.fit(self.X, self.X) + + with np.testing.assert_raises(ValueError): + neigh.score(self.X, self.X, sample_weight=[1,2,3]) + + def test_multivariate_response_score(self): + + neigh = RadiusNeighborsFunctionalRegressor() + y = make_multimodal_samples(n_samples=5, ndim_domain=2, random_state=0) + neigh.fit(self.X[:5], y) + + # It is not supported the multivariate score by the moment + with np.testing.assert_raises(ValueError): + neigh.score(self.X[:5], y) if __name__ == '__main__': print() From eb0c81d0017ede4507627b60083c9571ed53437e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Mon, 29 Jul 2019 00:41:22 +0200 Subject: [PATCH 156/222] Update skfda/preprocessing/smoothing/_basis.py Co-Authored-By: Pablo Marcos --- skfda/preprocessing/smoothing/_basis.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py index d532218e9..65c2dffed 100644 --- a/skfda/preprocessing/smoothing/_basis.py +++ b/skfda/preprocessing/smoothing/_basis.py @@ -489,7 +489,7 @@ def fit_transform(self, X: FDataGrid, y=None): if self.return_basis: return fdatabasis else: - return fdatabasis(self.output_points_) + return fdatabasis.to_grid(eval_points=self.output_points_) return self From 73ec1d07ea2d6aae469481a3ed30eaa426f4b273 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 29 Jul 2019 16:02:54 +0200 Subject: [PATCH 157/222] Correct PR comments. --- skfda/misc/_lfd.py | 2 +- skfda/preprocessing/smoothing/_basis.py | 30 +++++++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/skfda/misc/_lfd.py b/skfda/misc/_lfd.py index cf02b19e4..af991d5c4 100644 --- a/skfda/misc/_lfd.py +++ b/skfda/misc/_lfd.py @@ -88,7 +88,7 @@ def __repr__(self): """Representation of Lfd object.""" bwtliststr = "" - for i in range(self.order): + for i in range(self.order + 1): bwtliststr = bwtliststr + "\n" + self.weights[i].__repr__() + "," return (f"{self.__class__.__name__}(" diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py index 65c2dffed..292183a04 100644 --- a/skfda/preprocessing/smoothing/_basis.py +++ b/skfda/preprocessing/smoothing/_basis.py @@ -117,8 +117,7 @@ def transform(self, estimator, X, y=None): @ estimator._cached_coef_matrix.T) fdatabasis = FDataBasis( - basis=estimator.basis, coefficients=coefficients, - keepdims=estimator.keepdims) + basis=estimator.basis, coefficients=coefficients) return fdatabasis else: @@ -191,12 +190,17 @@ class BasisSmoother(_LinearSmoother): the least squares method. The values admitted are 'cholesky', 'qr' and 'matrix' for Cholesky and QR factorisation methods, and matrix inversion respectively. The default is 'cholesky'. + output_points (ndarray, optional): The output points. If ommited, + the input points are used. If ``return_basis`` is ``True``, this + parameter is ignored. return_basis (boolean): If ``False`` (the default) returns the smoothed data as an FDataGrid, like the other smoothers. If ``True`` returns a FDataBasis object. Examples: - By default, the data is converted to basis form without smoothing: + + By default, this smoother returns a FDataGrid, like the other + smoothers: >>> import numpy as np >>> import skfda @@ -205,6 +209,21 @@ class BasisSmoother(_LinearSmoother): >>> x array([ 1., 1., -1., -1., 1.]) + >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) + >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( + ... basis, method='cholesky') + >>> fd_smooth = smoother.fit_transform(fd) + >>> fd_smooth.data_matrix.round(2) + array([[[ 1.], + [ 1.], + [-1.], + [-1.], + [ 1.]]]) + + However, the parameter ``return_basis`` can be used to return the data + in basis form, by default, without extra smoothing: + >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( @@ -299,7 +318,6 @@ def __init__(self, penalty_matrix=None, output_points=None, method='cholesky', - keepdims=False, return_basis=False): self.basis = basis self.smoothing_parameter = smoothing_parameter @@ -308,7 +326,6 @@ def __init__(self, self.penalty_matrix = penalty_matrix self.output_points = output_points self.method = method - self.keepdims = keepdims self.return_basis = return_basis def _method_function(self): @@ -483,8 +500,7 @@ def fit_transform(self, X: FDataGrid, y=None): f"({data_matrix.shape[0]}).") fdatabasis = FDataBasis( - basis=self.basis, coefficients=coefficients, - keepdims=self.keepdims) + basis=self.basis, coefficients=coefficients) if self.return_basis: return fdatabasis From 40cd4a22d4ab41f873e7cb69b164032a0f2e79ee Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 29 Jul 2019 18:05:13 +0200 Subject: [PATCH 158/222] Add backreferences to examples --- docs/Makefile | 1 + docs/_templates/autosummary/base.rst | 7 ++++++ docs/_templates/autosummary/class.rst | 31 +++++++++++++++++++++++++++ docs/conf.py | 9 ++++---- 4 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 docs/_templates/autosummary/base.rst create mode 100644 docs/_templates/autosummary/class.rst diff --git a/docs/Makefile b/docs/Makefile index 6ba1cf8d8..09cb3d000 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -60,6 +60,7 @@ clean: rm -rf modules/misc/autosummary rm -rf modules/ml/autosummary rm -rf modules/ml/clustering/autosummary + rm -rf backreferences .PHONY: html html: diff --git a/docs/_templates/autosummary/base.rst b/docs/_templates/autosummary/base.rst new file mode 100644 index 000000000..27f71e506 --- /dev/null +++ b/docs/_templates/autosummary/base.rst @@ -0,0 +1,7 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. auto{{ objtype }}:: {{ objname }} + +.. include:: {{package}}/backreferences/{{fullname}}.examples \ No newline at end of file diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst new file mode 100644 index 000000000..5d4fff393 --- /dev/null +++ b/docs/_templates/autosummary/class.rst @@ -0,0 +1,31 @@ +{{ fullname | escape | underline}} + +.. currentmodule:: {{ module }} + +.. autoclass:: {{ objname }} + + {% block methods %} + .. automethod:: __init__ + + {% if methods %} + .. rubric:: Methods + + .. autosummary:: + {% for item in methods %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + + {% block attributes %} + {% if attributes %} + .. rubric:: Attributes + + .. autosummary:: + {% for item in attributes %} + ~{{ name }}.{{ item }} + {%- endfor %} + {% endif %} + {% endblock %} + +.. include:: {{package}}/backreferences/{{fullname}}.examples \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index 7f03dc329..2d446b78b 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -53,7 +53,7 @@ 'sphinx_rtd_theme', 'sphinx_gallery.gen_gallery', 'sphinx.ext.intersphinx', - 'sphinx.ext.doctest' ] + 'sphinx.ext.doctest'] autodoc_default_flags = ['members', 'inherited-members'] @@ -112,9 +112,9 @@ # documentation. # html_theme_options = { - 'logo_only': True, - 'style_nav_header_background': 'Gainsboro', - } + 'logo_only': True, + 'style_nav_header_background': 'Gainsboro', +} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, @@ -230,6 +230,7 @@ 'skfda': None, }, 'backreferences_dir': 'backreferences', + 'doc_module': 'skfda', } autosummary_generate = True From dff7c57fb3a81c108c1e723bdf3270bdfd948e84 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 30 Jul 2019 16:38:52 +0200 Subject: [PATCH 159/222] Fix pdf compilation bugs --- docs/conf.py | 2 ++ examples/plot_interpolation.py | 20 ++++++------- skfda/datasets/_samples_generators.py | 18 ++++++------ .../visualization/magnitude_shape_plot.py | 29 ++++++++++--------- 4 files changed, 37 insertions(+), 32 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 2d446b78b..7c8c6d3b1 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -143,6 +143,8 @@ # -- Options for LaTeX output --------------------------------------------- +latex_engine = 'lualatex' + latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # diff --git a/examples/plot_interpolation.py b/examples/plot_interpolation.py index 286a3916d..727b18e3e 100644 --- a/examples/plot_interpolation.py +++ b/examples/plot_interpolation.py @@ -11,12 +11,14 @@ # sphinx_gallery_thumbnail_number = 3 -import skfda -import numpy as np -import matplotlib.pyplot as plt from mpl_toolkits.mplot3d import axes3d + +import matplotlib.pyplot as plt +import numpy as np +import skfda from skfda.representation.interpolation import SplineInterpolator + ############################################################################### # The :class:`FDataGrid` class is used for datasets containing discretized # functions. For the evaluation between the points of discretization, or sample @@ -25,9 +27,8 @@ # We will construct an example dataset with two curves with 6 points of # discretization. # - fd = skfda.datasets.make_sinusoidal_process(n_samples=2, n_features=6, - random_state=1) + random_state=1) fd.scatter() plt.legend(["Sample 1", "Sample 2"]) @@ -41,7 +42,7 @@ fd.plot() fd.scatter() -################################################################################ +########################################################################## # The interpolation method of the FDataGrid could be changed setting the # attribute `interpolator`. Once we have set an interpolator it is used for # the evaluation of the object. @@ -63,7 +64,7 @@ # Sample with noise fd_smooth = skfda.datasets.make_sinusoidal_process(n_samples=1, n_features=30, - random_state=1, error_std=.3) + random_state=1, error_std=.3) # Cubic interpolator fd_smooth.interpolator = SplineInterpolator(interpolation_order=3) @@ -124,9 +125,8 @@ fd_monotone.plot(linestyle='--', label="cubic") - fd_monotone.interpolator = SplineInterpolator(interpolation_order=3, - monotone=True) + monotone=True) fd_monotone.plot(label="PCHIP") fd_monotone.scatter(c='C1') @@ -143,7 +143,7 @@ X, Y, Z = axes3d.get_test_data(1.2) data_matrix = [Z.T] -sample_points = [X[0,:], Y[:, 0]] +sample_points = [X[0, :], Y[:, 0]] fd = skfda.FDataGrid(data_matrix, sample_points) diff --git a/skfda/datasets/_samples_generators.py b/skfda/datasets/_samples_generators.py index 8af1417a8..4ad48bbb8 100644 --- a/skfda/datasets/_samples_generators.py +++ b/skfda/datasets/_samples_generators.py @@ -1,11 +1,13 @@ +import scipy.integrate +from scipy.stats import multivariate_normal import sklearn.utils + import numpy as np -from scipy.stats import multivariate_normal -import scipy.integrate -from ..misc import covariances + from .. import FDataGrid -from ..representation.interpolation import SplineInterpolator +from ..misc import covariances from ..preprocessing.registration import normalize_warping +from ..representation.interpolation import SplineInterpolator def make_gaussian_process(n_samples: int = 100, n_features: int = 100, *, @@ -61,7 +63,6 @@ def make_sinusoidal_process(n_samples: int = 15, n_features: int = 100, *, phase_std: float = .6, amplitude_mean: float = 1., amplitude_std: float = .05, error_std: float = .2, random_state=None): - r"""Generate sinusoidal proccess. Each sample :math:`x_i(t)` is generated as: @@ -165,7 +166,6 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, stop: float = 1., std: float = .05, mode_std: float = .02, noise: float = .0, modes_location=None, random_state=None): - r"""Generate multimodal samples. Each sample :math:`x_i(t)` is proportional to a gaussian mixture, generated @@ -174,10 +174,10 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, .. math:: - x_i(t) \propto \sum_{n=1}^{\text{n_modes}} \exp \left ( + x_i(t) \propto \sum_{n=1}^{\text{n\_modes}} \exp \left ( {-\frac{1}{2\sigma} (t-\mu_n)^T \mathbb{1} (t-\mu_n)} \right ) - Where :math:`\mu_n=\text{mode_location}_n+\epsilon` and :math:`\epsilon` + Where :math:`\mu_n=\text{mode\_location}_n+\epsilon` and :math:`\epsilon` is normally distributed, with mean :math:`\mathbb{0}` and standard deviation given by the parameter `std`. @@ -286,7 +286,7 @@ def make_random_warping(n_samples: int = 15, n_features: int = 100, *, An affine traslation it is used to define the warping in :math:`[a,b]`. The smoothing and shape of the warpings can be controlling changing - :math:`N`, :math:`\sigma` and :math:`K= 1 + \text{shape_parameter}`. + :math:`N`, :math:`\sigma` and :math:`K= 1 + \text{shape\_parameter}`. Args: diff --git a/skfda/exploratory/visualization/magnitude_shape_plot.py b/skfda/exploratory/visualization/magnitude_shape_plot.py index 1cebdbc41..3e1bf29af 100644 --- a/skfda/exploratory/visualization/magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/magnitude_shape_plot.py @@ -6,19 +6,22 @@ """ -import numpy as np +from io import BytesIO + import matplotlib -import matplotlib.pyplot as plt -import scipy.integrate -from sklearn.covariance import MinCovDet -from scipy.stats import f, variation from numpy import linalg as la -from io import BytesIO import scipy +import scipy.integrate +from scipy.stats import f, variation +from sklearn.covariance import MinCovDet -from ... import FDataGrid +import matplotlib.pyplot as plt +import numpy as np from skfda.exploratory.depth import modified_band_depth +from ... import FDataGrid + + __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" @@ -52,8 +55,8 @@ def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, distribution :math:`F`, :math:`d` a depth function and :math:`\mathbf{v}(t) = \left\{ \mathbf{X}(t) - \mathbf{Z}(t)\right\} / \lVert \mathbf{X}(t) - \mathbf{Z}(t) \rVert` is the spatial sign of :math:`\left\{\mathbf{X}(t) - - \mathbf{Z}(t)\right\}`, :math:`\mathbf{Z}(t)` denotes the median and ∥ · ∥ - denotes the :math:`L_2` norm. + \mathbf{Z}(t)\right\}`, :math:`\mathbf{Z}(t)` denotes the median and + :math:`\lVert \cdot \rVert` denotes the :math:`L_2` norm. From the above formula, we define the mean directional outlyingness as: @@ -252,7 +255,7 @@ class MagnitudeShapePlot: Then, the tail of this distance distribution is approximated as follows: .. math:: - \frac{c\left(m − p\right)}{m\left(p + 1\right)}RMD^2\left( + \frac{c\left(m - p\right)}{m\left(p + 1\right)}RMD^2\left( \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right)\sim F_{p+1, m-p} where :math:`p` is the dmension of the image, and :math:`c` and :math:`m` @@ -271,9 +274,9 @@ class MagnitudeShapePlot: elements of the MCD shape estimator. Finally, we choose a cutoff value to determine the outliers, C , - as the α quantile of :math:`F_{p+1, m-p}`. We set :math:`\alpha = 0.993`, - which is used in the classical boxplot for detecting outliers under a - normal distribution. + as the :math:`\alpha` quantile of :math:`F_{p+1, m-p}`. We set + :math:`\alpha = 0.993`, which is used in the classical boxplot for + detecting outliers under a normal distribution. Attributes: fdatagrid (FDataGrid): Object to be visualized. From 09d713cf44f761153c1d302631b5bd7cb8630af5 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 31 Jul 2019 16:03:15 +0200 Subject: [PATCH 160/222] Correct #6. --- examples/plot_representation.py | 16 +- .../registration/_landmark_registration.py | 2 +- skfda/representation/basis.py | 140 +++++++++++------- skfda/representation/grid.py | 8 +- tests/test_pandas.py | 2 +- 5 files changed, 103 insertions(+), 65 deletions(-) diff --git a/examples/plot_representation.py b/examples/plot_representation.py index cda6a5c4a..318d5fd91 100644 --- a/examples/plot_representation.py +++ b/examples/plot_representation.py @@ -9,8 +9,9 @@ # License: MIT import skfda -from skfda.representation.interpolation import SplineInterpolator import skfda.representation.basis as basis +from skfda.representation.interpolation import SplineInterpolator + ############################################################################### # In this example we are going to show the different representations of @@ -79,8 +80,8 @@ ############################################################################### # We will represent it using a basis of B-splines. fd_basis = fd.to_basis( - basis.BSpline(domain_range=fd.domain_range[0], nbasis=4) - ) + basis.BSpline(nbasis=4) +) fd_basis.plot() @@ -88,8 +89,8 @@ # We can increase the number of elements in the basis to try to reproduce the # original data with more fidelity. fd_basis_big = fd.to_basis( - basis.BSpline(domain_range=fd.domain_range[0], nbasis=7) - ) + basis.BSpline(nbasis=7) +) fd_basis_big.plot() @@ -108,12 +109,11 @@ # points if the period is equal to the domain range, so this basis is clearly # non suitable for the Growth dataset. fd_basis = fd.to_basis( - basis.Fourier(domain_range=fd.domain_range[0], nbasis=7) - ) + basis.Fourier(nbasis=7) +) fd_basis.plot() ############################################################################## # The data is now represented as the coefficients in the basis expansion. print(fd_basis) - diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index ae89e5a77..ef27eb1e0 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -319,7 +319,7 @@ def landmark_registration(fd, landmarks, *, location=None, eval_points=None): This method will work for FDataBasis as for FDataGrids - >>> fd = fd.to_basis(BSpline(nbasis=12, domain_range=(-1,1))) + >>> fd = fd.to_basis(BSpline(nbasis=12)) >>> landmark_registration(fd, landmarks) FDataBasis(...) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 38e7fbab3..833aabb31 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -4,27 +4,31 @@ the corresponding basis classes. """ -import copy from abc import ABC, abstractmethod +import copy -import numpy as np +from numpy import polyder, polyint, polymul, polyval +import pandas.api.extensions import scipy.integrate +from scipy.interpolate import BSpline as SciBSpline +from scipy.interpolate import PPoly import scipy.interpolate import scipy.linalg -from numpy import polyder, polyint, polymul, polyval -from scipy.interpolate import PPoly -from scipy.interpolate import BSpline as SciBSpline from scipy.special import binom -from . import grid +import numpy as np + from . import FData +from . import grid from .._utils import _list_of_arrays, constants -import pandas.api.extensions + __author__ = "Miguel Carbajo Berrocal" __email__ = "miguel.carbajo@estudiante.uam.es" # aux functions + + def _polypow(p, n=2): if n > 2: return polymul(p, _polypow(p, n - 1)) @@ -58,7 +62,7 @@ class Basis(ABC): """ - def __init__(self, domain_range=(0, 1), nbasis=1): + def __init__(self, domain_range=None, nbasis=1): """Basis constructor. Args: @@ -67,22 +71,34 @@ def __init__(self, domain_range=(0, 1), nbasis=1): nbasis: Number of functions that form the basis. Defaults to 1. """ - # TODO: Allow multiple dimensions - domain_range = _list_of_arrays(domain_range) + if domain_range is not None: + # TODO: Allow multiple dimensions + domain_range = _list_of_arrays(domain_range) - # Some checks - _check_domain(domain_range) + # Some checks + _check_domain(domain_range) if nbasis < 1: raise ValueError("The number of basis has to be strictly " "possitive.") - self.domain_range = domain_range + self._domain_range = domain_range self.nbasis = nbasis self._drop_index_lst = [] super().__init__() + @property + def domain_range(self): + if self._domain_range is None: + return [np.array([0, 1])] + else: + return self._domain_range + + @domain_range.setter + def domain_range(self, value): + self._domain_range = value + @abstractmethod def _compute_matrix(self, eval_points, derivative=0): """Compute the basis or its derivatives given a list of values. @@ -327,7 +343,6 @@ def _to_R(self): raise NotImplementedError def _inner_matrix(self, other=None): - r"""Return the Inner Product Matrix of a pair of basis. The Inner Product Matrix is defined as @@ -364,7 +379,6 @@ def _inner_matrix(self, other=None): return inner def gram_matrix(self): - r"""Return the Gram Matrix of a basis The Gram Matrix is defined as @@ -449,7 +463,7 @@ class Constant(Basis): """ - def __init__(self, domain_range=(0, 1)): + def __init__(self, domain_range=None): """Constant basis constructor. Args: @@ -531,9 +545,9 @@ def penalty(self, derivative_degree=None, coefficients=None): array([[ 0.]]) References: - .. [RS05-5-6-2-1] Ramsay, J., Silverman, B. W. (2005). Specifying the - roughness penalty. In *Functional Data Analysis* (pp. 106-107). - Springer. + .. [RS05-5-6-2-1] Ramsay, J., Silverman, B. W. (2005). Specifying + the roughness penalty. In *Functional Data Analysis* + (pp. 106-107). Springer. """ if derivative_degree is None: @@ -688,9 +702,9 @@ def penalty(self, derivative_degree=None, coefficients=None): [ 0., 0., 6., 12.]]) References: - .. [RS05-5-6-2-2] Ramsay, J., Silverman, B. W. (2005). Specifying the - roughness penalty. In *Functional Data Analysis* (pp. 106-107). - Springer. + .. [RS05-5-6-2-1] Ramsay, J., Silverman, B. W. (2005). Specifying + the roughness penalty. In *Functional Data Analysis* + (pp. 106-107). Springer. """ @@ -858,38 +872,46 @@ def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): if nbasis is None: raise ValueError("Must provide either a list of knots or the" "number of basis.") - if domain_range is None: - domain_range = (0, 1) - knots = list(np.linspace(*domain_range, nbasis - order + 2)) else: knots = list(knots) knots.sort() if domain_range is None: domain_range = (knots[0], knots[-1]) + else: + if domain_range[0] != knots[0] or domain_range[1] != knots[-1]: + raise ValueError("The ends of the knots must be the same " + "as the domain_range.") # nbasis default to number of knots + order of the splines - 2 if nbasis is None: nbasis = len(knots) + order - 2 - if domain_range is None: - domain_range = (knots[0], knots[-1]) if (nbasis - order + 2) < 2: raise ValueError(f"The number of basis ({nbasis}) minus the order " f"of the bspline ({order}) should be greater " f"than 3.") - if domain_range[0] != knots[0] or domain_range[1] != knots[-1]: - raise ValueError("The ends of the knots must be the same as " - "the domain_range.") + self.order = order + self.knots = None if knots is None else list(knots) + super().__init__(domain_range, nbasis) # Checks - if nbasis != order + len(knots) - 2: - raise ValueError("The number of basis has to equal the order " - "plus the number of knots minus 2.") + if self.nbasis != self.order + len(self.knots) - 2: + raise ValueError(f"The number of basis ({self.nbasis}) has to " + f"equal the order ({self.order}) plus the " + f"number of knots ({len(self.knots)}) minus 2.") - self.order = order - self.knots = list(knots) - super().__init__(domain_range, nbasis) + @property + def knots(self): + if self._knots is None: + return list(np.linspace(*self.domain_range[0], + self.nbasis - self.order + 2)) + else: + return self._knots + + @knots.setter + def knots(self, value): + self._knots = value def _ndegenerated(self, penalty_degree): """Return number of 0 or nearly to 0 eigenvalues of the penalty matrix. @@ -991,9 +1013,9 @@ def penalty(self, derivative_degree=None, coefficients=None): numpy.array: Penalty matrix. References: - .. [RS05-5-6-2-3] Ramsay, J., Silverman, B. W. (2005). Specifying the - roughness penalty. In *Functional Data Analysis* (pp. 106-107). - Springer. + .. [RS05-5-6-2-1] Ramsay, J., Silverman, B. W. (2005). Specifying + the roughness penalty. In *Functional Data Analysis* + (pp. 106-107). Springer. """ if derivative_degree is not None: @@ -1040,8 +1062,8 @@ def penalty(self, derivative_degree=None, coefficients=None): # meaning that the column i corresponds to the ith knot. # Let the ith not be a # Then f(x) = pp(x - a) - pp = (PPoly.from_spline((knots, c, self.order - 1)).c[:, - no_0_intervals]) + pp = (PPoly.from_spline( + (knots, c, self.order - 1)).c[:, no_0_intervals]) # We need the actual coefficients of f, not pp. So we # just recursively calculate the new coefficients coeffs = pp.copy() @@ -1062,7 +1084,7 @@ def penalty(self, derivative_degree=None, coefficients=None): for interval in range(len(no_0_intervals)): for i in range(self.nbasis): poly_i = np.trim_zeros(ppoly_lst[i][:, - interval], 'f') + interval], 'f') if len(poly_i) <= derivative_degree: # if the order of the polynomial is lesser or # equal to the derivative the result of the @@ -1077,7 +1099,7 @@ def penalty(self, derivative_degree=None, coefficients=None): for j in range(i + 1, self.nbasis): poly_j = np.trim_zeros(ppoly_lst[j][:, - interval], 'f') + interval], 'f') if len(poly_j) <= derivative_degree: # if the order of the polynomial is lesser # or equal to the derivative the result of @@ -1268,7 +1290,7 @@ class Fourier(Basis): """ - def __init__(self, domain_range=(0, 1), nbasis=3, period=None): + def __init__(self, domain_range=None, nbasis=3, period=None): """Construct a Fourier object. It forces the object to have an odd number of basis. If nbasis is @@ -1283,20 +1305,30 @@ def __init__(self, domain_range=(0, 1), nbasis=3, period=None): """ - domain_range = _list_of_arrays(domain_range) + if domain_range is not None: + domain_range = _list_of_arrays(domain_range) - if len(domain_range) != 1: - raise ValueError("Domain range should be unidimensional.") + if len(domain_range) != 1: + raise ValueError("Domain range should be unidimensional.") - domain_range = domain_range[0] + domain_range = domain_range[0] - if period is None: - period = domain_range[1] - domain_range[0] self.period = period # If number of basis is even, add 1 nbasis += 1 - nbasis % 2 super().__init__(domain_range, nbasis) + @property + def period(self): + if self._period is None: + return self.domain_range[0][1] - self.domain_range[0][0] + else: + return self._period + + @period.setter + def period(self, value): + self._period = value + def _compute_matrix(self, eval_points, derivative=0): """Compute the basis or its derivatives given a list of values. @@ -1423,9 +1455,9 @@ def penalty(self, derivative_degree=None, coefficients=None): numpy.array: Penalty matrix. References: - .. [RS05-5-6-2-4] Ramsay, J., Silverman, B. W. (2005). Specifying the - roughness penalty. In *Functional Data Analysis* (pp. 106-107). - Springer. + .. [RS05-5-6-2-1] Ramsay, J., Silverman, B. W. (2005). Specifying + the roughness penalty. In *Functional Data Analysis* + (pp. 106-107). Springer. """ if isinstance(derivative_degree, int): @@ -1548,6 +1580,7 @@ class _CoordinateIterator: Dummy object. Should be change to support multidimensional objects. """ + def __init__(self, fdatabasis): """Create an iterator through the image coordinates.""" self._fdatabasis = fdatabasis @@ -1873,7 +1906,6 @@ def _evaluate(self, eval_points, *, derivative=0): return res.reshape((self.nsamples, len(eval_points), 1)) def _evaluate_composed(self, eval_points, *, derivative=0): - r"""Evaluate the object or its derivatives at a list of values with a different time for each sample. diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index e9a3f2797..86c633d10 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -855,7 +855,7 @@ def to_basis(self, basis, **kwargs): array([ 1., 1., -1., -1., 1.]) >>> fd = FDataGrid(x, t) - >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> basis = skfda.representation.basis.Fourier(nbasis=3) >>> fd_b = fd.to_basis(basis) >>> fd_b.coefficients.round(2) array([[ 0. , 0.71, 0.71]]) @@ -867,6 +867,12 @@ def to_basis(self, basis, **kwargs): elif self.ndim_image > 1: raise NotImplementedError("Only support 1 dimension on the " "image.") + + # Readjust the domain range if there was not an explicit one + if basis._domain_range is None: + basis = basis.copy() + basis.domain_range = self.domain_range + return fdbasis.FDataBasis.from_data(self.data_matrix[..., 0], self.sample_points[0], basis, diff --git a/tests/test_pandas.py b/tests/test_pandas.py index cf25e9b3a..a650daa1e 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -10,7 +10,7 @@ def setUp(self): self.fd = skfda.FDataGrid( [[1, 2, 3, 4, 5, 6, 7], [2, 3, 4, 5, 6, 7, 9]]) self.fd_basis = self.fd.to_basis(skfda.representation.basis.BSpline( - domain_range=self.fd.domain_range, nbasis=5)) + nbasis=5)) def test_fdatagrid_series(self): series = pd.Series(self.fd) From a623ba63458299c41ff2bb2c134cff804b92813d Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 7 Aug 2019 16:42:27 +0200 Subject: [PATCH 161/222] Corrections in visualization - Depth measures were calculated independently for each image dimension for multivariate functional data. This is wrong. - As a result, functional and surface boxplots had to be corrected. - The functional boxplot did not compute the right minimum non-outlying envelope. - Depth tests were repeated in docstrings, so I have removed them. - Tests for multivariate depths have been removed until we implement proper multivariate depths. --- examples/plot_boxplot.py | 34 +- examples/plot_surface_boxplot.py | 128 +++--- skfda/exploratory/depth.py | 281 ++++--------- skfda/exploratory/outliers/_envelopes.py | 44 ++ skfda/exploratory/visualization/boxplot.py | 460 +++++++++++---------- skfda/representation/grid.py | 3 +- tests/test_depth.py | 93 ----- tests/test_fdata_boxplot.py | 97 ++--- 8 files changed, 449 insertions(+), 691 deletions(-) create mode 100644 skfda/exploratory/outliers/_envelopes.py delete mode 100644 tests/test_depth.py diff --git a/examples/plot_boxplot.py b/examples/plot_boxplot.py index 9850a6548..a70d22807 100644 --- a/examples/plot_boxplot.py +++ b/examples/plot_boxplot.py @@ -10,28 +10,29 @@ # sphinx_gallery_thumbnail_number = 2 -from skfda import datasets +import matplotlib.pyplot as plt +import numpy as np from skfda import FDataGrid +from skfda import datasets from skfda.exploratory.depth import band_depth, fraiman_muniz_depth -import matplotlib.pyplot as plt from skfda.exploratory.visualization.boxplot import Boxplot -import numpy as np -################################################################################## + +########################################################################## # First, the Canadian Weather dataset is downloaded from the package 'fda' in CRAN. # It contains a FDataGrid with daily temperatures and precipitations, that is, it # has a 2-dimensional image. We are interested only in the daily average temperatures, # so another FDataGrid is constructed with the desired values. - dataset = datasets.fetch_weather() fd = dataset["data"] fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], sample_points=fd.sample_points, dataset_label=fd.dataset_label, axes_labels=fd.axes_labels[0:2]) -############################################################################################ +########################################################################## # The data is plotted to show the curves we are working with. They are divided according to the -# target. In this case, it includes the different climates to which the weather stations belong to. +# target. In this case, it includes the different climates to which the +# weather stations belong to. # Each climate is assigned a color. Defaults to grey. colormap = plt.cm.get_cmap('seismic') @@ -44,7 +45,7 @@ label_names=label_names) -############################################################################################ +########################################################################## # We instantiate a :func:`functional boxplot object ` with the data, # and we call its :func:`plot function ` to show the graph. # @@ -58,7 +59,7 @@ plt.figure() fdBoxplot.plot() -############################################################################################ +########################################################################## # We can observe in the boxplot the median in black, the central region (where the 50% of the # most centered samples reside) in pink and the envelopes and vertical lines in blue. The # outliers detected, those samples with at least a point outside the outlying envelope, are @@ -70,11 +71,11 @@ outliercol = 0.7 plt.figure() -fd_temperatures.plot(sample_labels=fdBoxplot.outliers[0].astype(int), +fd_temperatures.plot(sample_labels=fdBoxplot.outliers.astype(int), label_colors=colormap([color, outliercol]), label_names=["nonoutliers", "outliers"]) -############################################################################################ +########################################################################## # The curves pointed as outliers are are those curves with significantly lower values to the # rest. This is the expected result due to the depth measure used, the :func:`modified band # depth ` which rank the samples according to @@ -86,15 +87,16 @@ # in order to designate some samples as outliers (otherwise, with this measure and the default # factor, none of the curves are pointed out as outliers). We can see that the outliers detected # belong to the Pacific and Arctic climates which are less common to find in Canada. As a -# consequence, this measure detects better shape outliers compared to the previous one. +# consequence, this measure detects better shape outliers compared to the +# previous one. -fdBoxplot = Boxplot(fd_temperatures, method=band_depth, factor = 0.4) +fdBoxplot = Boxplot(fd_temperatures, method=band_depth, factor=0.4) fdBoxplot.show_full_outliers = True plt.figure() fdBoxplot.plot() -############################################################################################ +########################################################################## # Another functionality implemented in this object is the enhanced functional boxplot, # which can include other central regions, apart from the central or 50% one. # @@ -103,11 +105,11 @@ # are specified. fdBoxplot = Boxplot(fd_temperatures, method=fraiman_muniz_depth, - prob = [0.75, 0.5, 0.25]) + prob=[0.75, 0.5, 0.25]) plt.figure() fdBoxplot.plot() -############################################################################################# +########################################################################## # The above two lines could be replaced just by fdBoxplot since the default representation of # the :func:`boxplot object ` is the image of the plot. However, due to # generation of this notebook it does not show the image and that is why the plot method is diff --git a/examples/plot_surface_boxplot.py b/examples/plot_surface_boxplot.py index 2c6443bda..b56f26489 100644 --- a/examples/plot_surface_boxplot.py +++ b/examples/plot_surface_boxplot.py @@ -2,8 +2,8 @@ Surface Boxplot ==================== -Shows the use of the surface boxplot, which is a generalization of the functional boxplot -for FDataGrid whose domain dimension is 2. +Shows the use of the surface boxplot, which is a generalization of the +functional boxplot for FDataGrid whose domain dimension is 2. """ # Author: Amanda Hernando Bernabé @@ -11,100 +11,82 @@ # sphinx_gallery_thumbnail_number = 3 -import numpy as np import matplotlib.pyplot as plt +import numpy as np from skfda import FDataGrid -from skfda.exploratory.visualization.boxplot import SurfaceBoxplot, Boxplot from skfda.datasets import make_sinusoidal_process, make_gaussian_process +from skfda.exploratory.visualization.boxplot import SurfaceBoxplot, Boxplot -################################################################################## -# In order to instantiate a :func:`surface boxplot object `, -# a functional data object with bidimensional domain must be generated. In this example, -# a FDataGrid representing a function :math:`f : \mathbb{R}^2\longmapsto\mathbb{R}^2` is -# constructed to show also the support of a multivariate dimensional image. The first -# dimension of the image contains sinusoidal processes and the second dimension, -# gaussian ones. -# -# First, the values are generated for each dimension with a function -# :math:`f : \mathbb{R}\longmapsto\mathbb{R}` implemented in the -# :func:`make_sinusoidal_process method ` and in the -# :func:`make_gaussian_process method `, respectively. -# Those functions return FDataGrid objects whose 'data_matrix' store the values needed. +############################################################################## +# In order to instantiate a :func:`surface boxplot object +# `, a functional data object with bidimensional +# domain must be generated. In this example, a FDataGrid representing a +# function :math:`f : \mathbb{R}^2\longmapsto\mathbb{R}` is constructed, +# using as an example a Brownian process extruded into another dimension. +# +# The values of the Brownian process are generated using +# :func:`make_gaussian_process method `, +# Those functions return FDataGrid objects whose 'data_matrix' +# store the values needed. n_samples = 10 n_features = 10 -fd1 = make_sinusoidal_process(n_samples = n_samples, n_features=n_features, - random_state=5) -fd1.dataset_label = "Sinusoidal process" -fd2 = make_gaussian_process(n_samples = n_samples, n_features=n_features, - random_state=1) -fd2.dataset_label = "Brownian process" +fd = make_gaussian_process(n_samples=n_samples, n_features=n_features, + random_state=1) +fd.dataset_label = "Brownian process" -################################################################################## -# After, those values generated for one dimension on the domain are propagated along -# another dimension, obtaining a three-dimensional matrix or cube (two-dimensional domain -# and one-dimensional image). This is done with both data matrices from the above FDataGrids. +############################################################################## +# After, those values generated for one dimension on the domain are extruded +# along another dimension, obtaining a three-dimensional matrix or cube +# (two-dimensional domain and one-dimensional image). -cube1 = np.repeat(fd1.data_matrix, n_features).reshape( - (n_samples, n_features, n_features)) -cube2 = np.repeat(fd2.data_matrix, n_features).reshape( +cube = np.repeat(fd.data_matrix, n_features).reshape( (n_samples, n_features, n_features)) -################################################################################## -# Finally, both three-dimensional matrices are merged together and the FDataGrid desired -# is obtained. The data is plotted. +############################################################################## +# We can plot now the extruded trajectories. -cube_2 = np.empty((n_samples, n_features, n_features, 2)) -cube_2[:, :, :, 0] = cube1 -cube_2[:, :, :, 1] = cube2 - -fd_2 = FDataGrid(data_matrix=cube_2, sample_points=np.tile(fd1.sample_points, (2,1)), - dataset_label = "Sinusoidal and Brownian processes") +fd_2 = FDataGrid(data_matrix=cube, + sample_points=np.tile(fd.sample_points, (2, 1)), + dataset_label="Extruded Brownian process") plt.figure() fd_2.plot() -################################################################################## -# Since matplotlib was initially designed with only two-dimensional plotting in mind, -# the three-dimensional plotting utilities were built on top of matplotlib's two-dimensional -# display, and the result is a convenient (if somewhat limited) set of tools for -# three-dimensional data visualization as we can observe. +############################################################################## +# Since matplotlib was initially designed with only two-dimensional plotting +# in mind, the three-dimensional plotting utilities were built on top of +# matplotlib's two-dimensional display, and the result is a convenient (if +# somewhat limited) set of tools for three-dimensional data visualization as +# we can observe. # -# For this reason, the profiles of the surfaces, which are contained in the first two -# generated functional data objects, are plotted below, to help to visualize the data. +# For this reason, the profiles of the surfaces, which are contained in the +# first two generated functional data objects, are plotted below, to help to +# visualize the data. -fig, ax = plt.subplots(1,2) -fd1.plot(ax=[ax[0]]) -fd2.plot(ax=[ax[1]]) +plt.figure() +fd.plot() -################################################################################## -# To terminate the example, the instantiation of the SurfaceBoxplot object is made, -# showing the surface boxplot which corresponds to our FDataGrid representing a -# function :math:`f : \mathbb{R}^2\longmapsto\mathbb{R}^2` with a sinusoidal process in the -# first dimension of the image and a gaussian one in the second one. +############################################################################## +# To terminate the example, the instantiation of the SurfaceBoxplot object is +# made, showing the surface boxplot which corresponds to our FDataGrid surfaceBoxplot = SurfaceBoxplot(fd_2) plt.figure() surfaceBoxplot.plot() -################################################################################## -# The default representation of the object its the graph. - -surfaceBoxplot - -################################################################################## -# The surface boxplot contains the median, the central envelope and the outlying envelope -# plotted from darker to lighter colors, although they can be customized. +############################################################################## +# The surface boxplot contains the median, the central envelope and the +# outlying envelope plotted from darker to lighter colors, although they can +# be customized. # -# Analogous to the procedure followed before of plotting the three-dimensional data -# and their correponding profiles, we can obtain also the functional boxplot for -# one-dimensional data with the :func:`fdboxplot function ` -# passing as arguments the first two FdataGrid objects. The profile of the surface -# boxplot is obtained. - -fig, ax = plt.subplots(1,2) -boxplot1 = Boxplot(fd1) -boxplot1.plot(ax=[ax[0]]) -boxplot2 = Boxplot(fd2) -boxplot2.plot(ax=[ax[1]]) \ No newline at end of file +# Analogous to the procedure followed before of plotting the three-dimensional +# data and their correponding profiles, we can obtain also the functional +# boxplot for one-dimensional data with the :func:`fdboxplot function +# ` passing as arguments the first FdataGrid +# object. The profile of the surface boxplot is obtained. + +plt.figure() +boxplot1 = Boxplot(fd) +boxplot1.plot() diff --git a/skfda/exploratory/depth.py b/skfda/exploratory/depth.py index 596acf3c2..d8cff006d 100644 --- a/skfda/exploratory/depth.py +++ b/skfda/exploratory/depth.py @@ -3,11 +3,16 @@ This module includes different methods to order functional data, from the center (larger values) outwards(smaller ones).""" -import numpy as np +from functools import reduce +import itertools + +import scipy.integrate from scipy.stats import rankdata + +import numpy as np + from .. import FDataGrid -import itertools -from functools import reduce + __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" @@ -32,72 +37,36 @@ def _rank_samples(fdatagrid): >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> _rank_samples(fd) - array([[[ 4.], - [ 4.], - [ 4.], - [ 4.], - [ 4.], - [ 4.]], - - [[ 3.], - [ 3.], - [ 3.], - [ 3.], - [ 3.], - [ 3.]], - - [[ 1.], - [ 1.], - [ 2.], - [ 2.], - [ 2.], - [ 2.]], - - [[ 2.], - [ 2.], - [ 2.], - [ 1.], - [ 1.], - [ 1.]]]) - - Multivariate Setting: - - >>> data_matrix = [[[[1, 3], [2, 6]], - ... [[23, 54], [43, 76]], - ... [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], - ... [[67, 43], [32, 21]], - ... [[10, 24], [3, 12]]]] - >>> sample_points = [[2, 4, 6], [3, 6]] + array([[ 4., 4., 4., 4., 4., 4.], + [ 3., 3., 3., 3., 3., 3.], + [ 1., 1., 2., 2., 2., 2.], + [ 2., 2., 2., 1., 1., 1.]]) + + Several input dimensions: + + >>> data_matrix = [[[[1], [0.7], [1]], + ... [[4], [0.4], [5]]], + ... [[[2], [0.5], [2]], + ... [[3], [0.6], [3]]]] + >>> sample_points = [[2, 4], [3, 6, 8]] >>> fd = FDataGrid(data_matrix, sample_points) >>> _rank_samples(fd) - array([[[[ 1., 1.], - [ 1., 1.]], - - [[ 1., 2.], - [ 2., 2.]], - - [[ 1., 2.], - [ 2., 2.]]], - - - [[[ 2., 2.], - [ 2., 2.]], - - [[ 2., 1.], - [ 1., 1.]], - - [[ 2., 1.], - [ 1., 1.]]]]) + array([[[ 1., 2., 1.], + [ 2., 1., 2.]], + [[ 2., 1., 2.], + [ 1., 2., 1.]]]) + + + """ - ranks = np.zeros(fdatagrid.shape) - ncols_dim_image = np.asarray([range(fdatagrid.shape[i]) - for i in range(len(fdatagrid.shape) - 1, - 0, -1) - ]) - tuples = list(itertools.product(*ncols_dim_image)) - for t in tuples: - ranks.T[t] = rankdata(fdatagrid.data_matrix.T[t], method='max') + if fdatagrid.ndim_image > 1: + raise ValueError("Currently multivariate data is not allowed") + + ranks = np.zeros(fdatagrid.data_matrix.shape[:-1]) + + for index, _ in np.ndenumerate(ranks[0]): + ranks[(slice(None),) + index] = rankdata( + fdatagrid.data_matrix[(slice(None),) + index + (0,)], method='max') return ranks @@ -125,7 +94,6 @@ def band_depth(fdatagrid, pointwise=False): if pointwise equals to True. Examples: - Univariate setting: >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], @@ -134,28 +102,7 @@ def band_depth(fdatagrid, pointwise=False): >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) >>> band_depth(fd) - array([[ 0.5 ], - [ 0.83333333], - [ 0.5 ], - [ 0.5 ]]) - - Multivariate Setting: - - >>> data_matrix = [[[[1, 3], [2, 6]], - ... [[23, 54], [43, 76]], - ... [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], - ... [[67, 43], [32, 21]], - ... [[10, 24], [3, 12]]], - ... [[[4, 6], [4, 10]], - ... [[45, 48], [38, 56]], - ... [[8, 36], [10, 28]]]] - >>> sample_points = [[2, 4, 6], [3, 6]] - >>> fd = FDataGrid(data_matrix, sample_points) - >>> band_depth(fd) - array([[ 0.66666667, 0.66666667], - [ 0.66666667, 0.66666667], - [ 1. , 1. ]]) + array([ 0.5 , 0.83333333, 0.5 , 0.5 ]) """ n = fdatagrid.nsamples @@ -200,7 +147,6 @@ def modified_band_depth(fdatagrid, pointwise=False): returned if pointwise equals to True. Examples: - Univariate setting specifying pointwise: >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], @@ -208,55 +154,14 @@ def modified_band_depth(fdatagrid, pointwise=False): ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) - >>> modified_band_depth(fd, pointwise = True) - (array([[ 0.5 ], - [ 0.83333333], - [ 0.72222222], - [ 0.66666667]]), array([[[ 0.5 ], - [ 0.5 ], - [ 0.5 ], - [ 0.5 ], - [ 0.5 ], - [ 0.5 ]], - - [[ 0.83333333], - [ 0.83333333], - [ 0.83333333], - [ 0.83333333], - [ 0.83333333], - [ 0.83333333]], - - [[ 0.5 ], - [ 0.5 ], - [ 0.83333333], - [ 0.83333333], - [ 0.83333333], - [ 0.83333333]], - - [[ 0.83333333], - [ 0.83333333], - [ 0.83333333], - [ 0.5 ], - [ 0.5 ], - [ 0.5 ]]])) - - Multivariate Setting without specifying pointwise: - - >>> data_matrix = [[[[1, 3], [2, 6]], - ... [[23, 54], [43, 76]], - ... [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], - ... [[67, 43], [32, 21]], - ... [[10, 24], [3, 12]]], - ... [[[4, 6], [4, 10]], - ... [[45, 48], [38, 56]], - ... [[34, 78], [10, 28]]]] - >>> sample_points = [[2, 4, 6], [3, 6]] - >>> fd = FDataGrid(data_matrix, sample_points) - >>> modified_band_depth(fd) - array([[ 0.66666667, 0.72222222], - [ 0.72222222, 0.66666667], - [ 0.94444444, 0.94444444]]) + >>> depth, pointwise = modified_band_depth(fd, pointwise = True) + >>> depth.round(2) + array([ 0.5 , 0.83, 0.72, 0.67]) + >>> pointwise.round(2) + array([[ 0.5 , 0.5 , 0.5 , 0.5 , 0.5 , 0.5 ], + [ 0.83, 0.83, 0.83, 0.83, 0.83, 0.83], + [ 0.5 , 0.5 , 0.83, 0.83, 0.83, 0.83], + [ 0.83, 0.83, 0.83, 0.5 , 0.5 , 0.5 ]]) """ n = fdatagrid.nsamples @@ -317,8 +222,9 @@ def fraiman_muniz_depth(fdatagrid, pointwise=False): Where :math:`F` stands for the marginal univariate distribution function of each column. - The depth of a sample is the result of adding the previously computed depth - for each of its points. + The depth of a sample is the result of integrating the previously computed + depth for each of its points and normalizing dividing by the length of + the interval. Args: fdatagrid (FDataGrid): Object over whose samples the FM depth is going @@ -328,14 +234,13 @@ def fraiman_muniz_depth(fdatagrid, pointwise=False): Returns: depth (numpy.darray): Array containing the FM depth of the samples. - - Returns: depth_pointwise (numpy.darray, optional): Array containing the FM depth of the samples at each point of discretisation. Only returned if pointwise equals to True. Examples: - Univariate setting specifying pointwise: + Currently, this depth function can only be used + for univariate functional data: >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], @@ -343,75 +248,37 @@ def fraiman_muniz_depth(fdatagrid, pointwise=False): ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = FDataGrid(data_matrix, sample_points) - >>> fraiman_muniz_depth(fd, pointwise = True) - (array([[ 0.5 ], - [ 0.75 ], - [ 0.91666667], - [ 0.875 ]]), array([[[ 0.5 ], - [ 0.5 ], - [ 0.5 ], - [ 0.5 ], - [ 0.5 ], - [ 0.5 ]], - - [[ 0.75], - [ 0.75], - [ 0.75], - [ 0.75], - [ 0.75], - [ 0.75]], - - [[ 0.75], - [ 0.75], - [ 1. ], - [ 1. ], - [ 1. ], - [ 1. ]], - - [[ 1. ], - [ 1. ], - [ 1. ], - [ 0.75], - [ 0.75], - [ 0.75]]])) - - Multivariate Setting without specifying pointwise: - - >>> data_matrix = [[[[1, 3], [2, 6]], - ... [[23, 54], [43, 76]], - ... [[2, 45], [12, 65]]], - ... [[[21, 34], [8, 16]], - ... [[67, 43], [32, 21]], - ... [[10, 24], [3, 12]]], - ... [[[4, 6], [4, 10]], - ... [[45, 48], [38, 56]], - ... [[34, 78], [10, 28]]]] - >>> sample_points = [[2, 4, 6], [3, 6]] - >>> fd = FDataGrid(data_matrix, sample_points) >>> fraiman_muniz_depth(fd) - array([[ 0.72222222, 0.66666667], - [ 0.66666667, 0.72222222], - [ 0.77777778, 0.77777778]]) + array([ 0.5 , 0.75 , 0.925, 0.875]) + + You can use ``pointwise`` to obtain the pointwise depth, + before the integral is applied. + + >>> depth, pointwise = fraiman_muniz_depth(fd, pointwise = True) + >>> pointwise + array([[ 0.5 , 0.5 , 0.5 , 0.5 , 0.5 , 0.5 ], + [ 0.75, 0.75, 0.75, 0.75, 0.75, 0.75], + [ 0.75, 0.75, 1. , 1. , 1. , 1. ], + [ 1. , 1. , 1. , 0.75, 0.75, 0.75]]) + """ - univariate_depth = np.zeros(fdatagrid.shape) + if fdatagrid.ndim_domain > 1 or fdatagrid.ndim_image > 1: + raise ValueError("Currently multivariate data is not allowed") - ncols_dim_image = np.asarray([range(fdatagrid.shape[i]) - for i in range(len(fdatagrid.shape) - 1, - 0, -1) - ]) + pointwise_depth = np.array([ + 1 - abs(0.5 - _cumulative_distribution( + fdatagrid.data_matrix[:, i, 0]) + ) for i in range(len(fdatagrid.sample_points[0]))]).T - tuples = list(itertools.product(*ncols_dim_image)) - for t in tuples: - column = fdatagrid.data_matrix.T[t] - univariate_depth.T[t] = 1 - abs(0.5 - _cumulative_distribution(column)) + interval_len = (fdatagrid.domain_range[0][1] + - fdatagrid.domain_range[0][0]) - axis = tuple(range(1, fdatagrid.ndim_domain + 1)) - npoints_sample = reduce(lambda x, y: x * len(y), - fdatagrid.sample_points, 1) + depth = (scipy.integrate.simps(pointwise_depth, + fdatagrid.sample_points[0]) + / interval_len) if pointwise: - return (np.sum(univariate_depth, axis=axis) / npoints_sample, - univariate_depth) + return depth, pointwise_depth else: - return np.sum(univariate_depth, axis=axis) / npoints_sample + return depth diff --git a/skfda/exploratory/outliers/_envelopes.py b/skfda/exploratory/outliers/_envelopes.py new file mode 100644 index 000000000..8b0a4dfb3 --- /dev/null +++ b/skfda/exploratory/outliers/_envelopes.py @@ -0,0 +1,44 @@ +import math + +import numpy as np + + +def _compute_region(fdatagrid, + indices_descending_depth, + prob): + indices_samples = indices_descending_depth[ + :math.ceil(fdatagrid.nsamples * prob)] + return fdatagrid[indices_samples] + + +def _compute_envelope(region): + max_envelope = np.max(region.data_matrix, axis=0) + min_envelope = np.min(region.data_matrix, axis=0) + + return min_envelope, max_envelope + + +def _predict_outliers(fdatagrid, non_outlying_threshold): + # A functional datum is considered an outlier if it has ANY point + # in ANY dimension outside the envelope for inliers + + min_threshold, max_threshold = non_outlying_threshold + + or_axes = tuple(i for i in range(1, fdatagrid.data_matrix.ndim)) + + below_outliers = np.any(fdatagrid.data_matrix < + min_threshold, axis=or_axes) + above_outliers = np.any(fdatagrid.data_matrix > + max_threshold, axis=or_axes) + + return below_outliers | above_outliers + + +def _non_outlying_threshold(central_envelope, factor): + iqr = central_envelope[1] - central_envelope[0] + non_outlying_threshold_max = central_envelope[1] + iqr * factor + non_outlying_threshold_min = central_envelope[0] - iqr * factor + non_outlying_threshold = (non_outlying_threshold_min, + non_outlying_threshold_max) + + return non_outlying_threshold diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/boxplot.py index 354efc835..7e4dd7326 100644 --- a/skfda/exploratory/visualization/boxplot.py +++ b/skfda/exploratory/visualization/boxplot.py @@ -4,16 +4,19 @@ visualize it. """ -import matplotlib -import matplotlib.pyplot as plt +from abc import ABC, abstractmethod +from io import BytesIO import math +import matplotlib + +import matplotlib.pyplot as plt import numpy as np -from skfda.exploratory.depth import modified_band_depth from ... import FDataGrid -from io import BytesIO -from abc import ABC, abstractmethod +from ..depth import modified_band_depth +from ..outliers import _envelopes + __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" @@ -54,7 +57,7 @@ def central_envelope(self): pass @property - def outlying_envelope(self): + def non_outlying_envelope(self): pass @property @@ -103,12 +106,13 @@ class Boxplot(FDataBoxplot): the median/s. central_envelope (array, (fdatagrid.ndim_image, 2, nsample_points)): contains the central envelope/s. - outlying_envelope (array, (fdatagrid.ndim_image, 2, nsample_points)): - contains the outlying envelope/s. + non_outlying_envelope (array, (fdatagrid.ndim_image, 2, + nsample_points)): + contains the non-outlying envelope/s. colormap (matplotlib.colors.LinearSegmentedColormap): Colormap from which the colors to represent the central regions are selected. - central_regions (array, (fdatagrid.ndim_image * ncentral_regions, 2, - nsample_points)): contains the central regions. + envelopes (array, (fdatagrid.ndim_image * ncentral_regions, 2, + nsample_points)): contains the region envelopes. outliers (array, (fdatagrid.ndim_image, fdatagrid.nsamples)): contains the outliers. barcol (string): Color of the envelopes and vertical lines. @@ -137,21 +141,18 @@ class Boxplot(FDataBoxplot): [ 3. ], [ 2.5], [ 2. ]], - [[ 0.5], [ 0.5], [ 1. ], [ 2. ], [ 1.5], [ 1. ]], - [[-1. ], [-1. ], [-0.5], [ 1. ], [ 1. ], [ 0.5]], - [[-0.5], [-0.5], [-0.5], @@ -166,15 +167,46 @@ class Boxplot(FDataBoxplot): interpolator=SplineInterpolator(interpolation_order=1, smoothness_parameter=0.0, monotone=False), keepdims=False), - median=array([[ 0.5, 0.5, 1. , 2. , 1.5, 1. ]]), - central envelope=array([[[ 0.5, 0.5, 1. , 2. , 1.5, 1. ], - [-1. , -1. , -0.5, 1. , 1. , 0.5]]]), - outlying envelope=array([[[ 1. , 1. , 2. , 3. , 2.25, - 1.75], - [-1. , -1. , -0.5 , -0.5 , 0.25, -0.25]]]), - central_regions=array([[[ 0.5, 0.5, 1. , 2. , 1.5, 1. ], - [-1. , -1. , -0.5, 1. , 1. , 0.5]]]), - outliers=array([[ 1., 0., 0., 1.]])) + median=array([[ 0.5], + [ 0.5], + [ 1. ], + [ 2. ], + [ 1.5], + [ 1. ]]), + central envelope=(array([[-1. ], + [-1. ], + [-0.5], + [ 1. ], + [ 1. ], + [ 0.5]]), array([[ 0.5], + [ 0.5], + [ 1. ], + [ 2. ], + [ 1.5], + [ 1. ]])), + non-outlying envelope=(array([[-1. ], + [-1. ], + [-0.5], + [ 1. ], + [ 1. ], + [ 0.5]]), array([[ 0.5], + [ 0.5], + [ 1. ], + [ 2. ], + [ 1.5], + [ 1. ]])), + envelopes=[(array([[-1. ], + [-1. ], + [-0.5], + [ 1. ], + [ 1. ], + [ 0.5]]), array([[ 0.5], + [ 0.5], + [ 1. ], + [ 2. ], + [ 1.5], + [ 1. ]]))], + outliers=array([ True, False, False, True])) """ @@ -207,67 +239,36 @@ def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], if min(prob) < 0 or max(prob) > 1: raise ValueError("Probabilities must be between 0 and 1.") - nsample_points = len(fdatagrid.sample_points[0]) - ncentral_regions = len(prob) - - self._median = np.ndarray((fdatagrid.ndim_image, nsample_points)) - self._central_envelope = np.ndarray((fdatagrid.ndim_image, 2, - nsample_points)) - self._outlying_envelope = np.ndarray((fdatagrid.ndim_image, 2, - nsample_points)) - self._central_regions = np.ndarray( - (fdatagrid.ndim_image * ncentral_regions, - 2, nsample_points)) - self._outliers = np.zeros((fdatagrid.ndim_image, fdatagrid.nsamples)) + self._envelopes = [None] * len(prob) depth = method(fdatagrid) - indices_descencing_depth = (-depth).argsort(axis=0) - - for m in range(fdatagrid.ndim_image): - - for i in range(len(prob)): - - indices_samples = indices_descencing_depth[:, m][ - :math.ceil(fdatagrid.nsamples * prob[i])] - samples_used = fdatagrid.data_matrix[indices_samples, :, m] - max_samples_used = np.amax(samples_used, axis=0) - min_samples_used = np.amin(samples_used, axis=0) - - if prob[i] == 0.5: - # central envelope - self._central_envelope[m] = np.asarray( - [max_samples_used.T, min_samples_used.T]) - - # outlying envelope - max_value = np.amax(fdatagrid.data_matrix[:, :, m], axis=0) - min_value = np.amin(fdatagrid.data_matrix[:, :, m], axis=0) - iqr = np.absolute(max_samples_used - min_samples_used) - outlying_max_envelope = np.minimum( - max_samples_used + iqr * factor, max_value) - outlying_min_envelope = np.maximum( - min_samples_used - iqr * factor, min_value) - self._outlying_envelope[m] = np.asarray( - [outlying_max_envelope.flatten(), - outlying_min_envelope.flatten()]) - - # outliers - for j in list(range(fdatagrid.nsamples)): - outliers_above = ( - outlying_max_envelope < fdatagrid.data_matrix[ - j, :, m]) - outliers_below = ( - outlying_min_envelope > fdatagrid.data_matrix[ - j, :, m]) - if (outliers_above.sum() > 0 or - outliers_below.sum() > 0): - self._outliers[m, j] = 1 - # central regions - self._central_regions[ncentral_regions * m + i] = np.asarray( - [max_samples_used.flatten(), min_samples_used.flatten()]) - - # mean sample - self._median[m] = fdatagrid.data_matrix[ - indices_descencing_depth[0, m], :, m].T + indices_descending_depth = (-depth).argsort(axis=0) + + # The median is the deepest curve + self._median = fdatagrid[indices_descending_depth[0] + ].data_matrix[0, ...] + + # Central region and envelope must be computed for outlier detection + central_region = _envelopes._compute_region( + fdatagrid, indices_descending_depth, 0.5) + self._central_envelope = _envelopes._compute_envelope(central_region) + + # Non-outlying envelope + non_outlying_threshold = _envelopes._non_outlying_threshold( + self._central_envelope, factor) + predicted_outliers = _envelopes._predict_outliers( + fdatagrid, non_outlying_threshold) + inliers = fdatagrid[predicted_outliers == 0] + self._non_outlying_envelope = _envelopes._compute_envelope(inliers) + + # Outliers + self._outliers = _envelopes._predict_outliers( + fdatagrid, self._non_outlying_envelope) + + for i, p in enumerate(prob): + region = _envelopes._compute_region( + fdatagrid, indices_descending_depth, p) + self._envelopes[i] = _envelopes._compute_envelope(region) self._fdatagrid = fdatagrid self._prob = prob @@ -290,12 +291,12 @@ def central_envelope(self): return self._central_envelope @property - def outlying_envelope(self): - return self._outlying_envelope + def non_outlying_envelope(self): + return self._non_outlying_envelope @property - def central_regions(self): - return self._central_regions + def envelopes(self): + return self._envelopes @property def outliers(self): @@ -347,52 +348,53 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): else: var_zorder = 4 + outliers = self.fdatagrid[self.outliers] + for m in range(self.fdatagrid.ndim_image): - # outliers - for j in list(range(self.fdatagrid.nsamples)): - if self.outliers[m, j]: - ax[m].plot(self.fdatagrid.sample_points[0], - self.fdatagrid.data_matrix[j, :, m], - color=self.outliercol, - linestyle='--', zorder=1) + # Outliers + for o in outliers: + ax[m].plot(o.sample_points[0], + o.data_matrix[0, :, m], + color=self.outliercol, + linestyle='--', zorder=1) for i in range(len(self._prob)): # central regions ax[m].fill_between(self.fdatagrid.sample_points[0], - self.central_regions[ - m * len(self._prob) + i, 0], - self.central_regions[ - m * len(self._prob) + i, 1], + self.envelopes[i][0][..., m], + self.envelopes[i][1][..., m], facecolor=color[i], zorder=var_zorder) # outlying envelope ax[m].plot(self.fdatagrid.sample_points[0], - self.outlying_envelope[m, 0], + self.non_outlying_envelope[0][..., m], self.fdatagrid.sample_points[0], - self.outlying_envelope[m, 1], color=self.barcol, - zorder=4) + self.non_outlying_envelope[1][..., m], + color=self.barcol, zorder=4) # central envelope ax[m].plot(self.fdatagrid.sample_points[0], - self.central_envelope[m, 0], + self.central_envelope[0][..., m], self.fdatagrid.sample_points[0], - self.central_envelope[m, 1], color=self.barcol, - zorder=4) + self.central_envelope[1][..., m], + color=self.barcol, zorder=4) # vertical lines index = math.ceil(self.fdatagrid.ncol / 2) x = self.fdatagrid.sample_points[0][index] - ax[m].plot([x, x], [self.outlying_envelope[m, 0][index], - self.central_envelope[m, 0][index]], + ax[m].plot([x, x], + [self.non_outlying_envelope[0][..., m][index], + self.central_envelope[0][..., m][index]], color=self.barcol, zorder=4) - ax[m].plot([x, x], [self.outlying_envelope[m, 1][index], - self.central_envelope[m, 1][index]], + ax[m].plot([x, x], + [self.non_outlying_envelope[1][..., m][index], + self.central_envelope[1][..., m][index]], color=self.barcol, zorder=4) # median sample - ax[m].plot(self.fdatagrid.sample_points[0], self.median[m], + ax[m].plot(self.fdatagrid.sample_points[0], self.median[..., m], color=self.mediancol, zorder=5) self.fdatagrid.set_labels(fig, ax) @@ -405,8 +407,8 @@ def __repr__(self): f"\nFDataGrid={repr(self.fdatagrid)}," f"\nmedian={repr(self.median)}," f"\ncentral envelope={repr(self.central_envelope)}," - f"\noutlying envelope={repr(self.outlying_envelope)}," - f"\ncentral_regions={repr(self.central_regions)}," + f"\nnon-outlying envelope={repr(self.non_outlying_envelope)}," + f"\nenvelopes={repr(self.envelopes)}," f"\noutliers={repr(self.outliers)})").replace('\n', '\n ') @@ -429,8 +431,8 @@ class SurfaceBoxplot(FDataBoxplot): the median/s. central_envelope (array, (fdatagrid.ndim_image, 2, lx, ly)): contains the central envelope/s. - outlying_envelope (array,(fdatagrid.ndim_image, 2, lx, ly)): - contains the outlying envelope/s. + non_outlying_envelope (array,(fdatagrid.ndim_image, 2, lx, ly)): + contains the non-outlying envelope/s. colormap (matplotlib.colors.LinearSegmentedColormap): Colormap from which the colors to represent the central regions are selected. boxcol (string): Color of the box, which includes median and central @@ -438,61 +440,70 @@ class SurfaceBoxplot(FDataBoxplot): outcol (string): Color of the outlying envelope. Example: - Function :math:`f : \mathbb{R^2}\longmapsto\mathbb{R^2}`. + Function :math:`f : \mathbb{R^2}\longmapsto\mathbb{R}`. - >>> data_matrix = [[[[1, 4], [0.3, 1.5], [1, 3]], - ... [[2, 8], [0.4, 2], [2, 9]]], - ... [[[2, 10], [0.5, 3], [2, 10]], - ... [[3, 12], [0.6, 3], [3, 15]]]] + >>> data_matrix = [[[[1], [0.7], [1]], + ... [[4], [0.4], [5]]], + ... [[[2], [0.5], [2]], + ... [[3], [0.6], [3]]]] >>> sample_points = [[2, 4], [3, 6, 8]] >>> fd = FDataGrid(data_matrix, sample_points, dataset_label="dataset", - ... axes_labels=["x1_label", "x2_label", - ... "y1_label", "y2_label"]) + ... axes_labels=["x1_label", "x2_label", "y_label"]) >>> SurfaceBoxplot(fd) SurfaceBoxplot( FDataGrid=FDataGrid( - array([[[[ 1. , 4. ], - [ 0.3, 1.5], - [ 1. , 3. ]], - - [[ 2. , 8. ], - [ 0.4, 2. ], - [ 2. , 9. ]]], - - - [[[ 2. , 10. ], - [ 0.5, 3. ], - [ 2. , 10. ]], - - [[ 3. , 12. ], - [ 0.6, 3. ], - [ 3. , 15. ]]]]), + array([[[[ 1. ], + [ 0.7], + [ 1. ]], + [[ 4. ], + [ 0.4], + [ 5. ]]], + [[[ 2. ], + [ 0.5], + [ 2. ]], + [[ 3. ], + [ 0.6], + [ 3. ]]]]), sample_points=[array([2, 4]), array([3, 6, 8])], domain_range=array([[2, 4], [3, 8]]), dataset_label='dataset', - axes_labels=['x1_label', 'x2_label', 'y1_label', 'y2_label'], + axes_labels=['x1_label', 'x2_label', 'y_label'], extrapolation=None, interpolator=SplineInterpolator(interpolation_order=1, smoothness_parameter=0.0, monotone=False), keepdims=False), - median=array([[[ 1. , 0.3, 1. ], - [ 2. , 0.4, 2. ]], - - [[ 4. , 1.5, 3. ], - [ 8. , 2. , 9. ]]]), - central envelope=array([[[[ 1. , 0.3, 1. ], - [ 2. , 0.4, 2. ]], - - [[ 1. , 0.3, 1. ], - [ 2. , 0.4, 2. ]]], - - - [[[ 4. , 1.5, 3. ], - [ 8. , 2. , 9. ]], - - [[ 4. , 1.5, 3. ], - [ 8. , 2. , 9. ]]]]), + median=array([[[ 1. ], + [ 0.7], + [ 1. ]], + [[ 4. ], + [ 0.4], + [ 5. ]]]), + central envelope=(array([[[ 1. ], + [ 0.7], + [ 1. ]], + [[ 4. ], + [ 0.4], + [ 5. ]]]), + array([[[ 1. ], + [ 0.7], + [ 1. ]], + [[ 4. ], + [ 0.4], + [ 5. ]]])), + outlying envelope=(array([[[ 1. ], + [ 0.7], + [ 1. ]], + [[ 4. ], + [ 0.4], + [ 5. ]]]), + array([[[ 1. ], + [ 0.7], + [ 1. ]], + [[ 4. ], + [ 0.4], + [ 5. ]]]))) + outlying envelope=array([[[[ 1. , 0.3, 1. ], [ 2. , 0.4, 2. ]], @@ -530,41 +541,24 @@ def __init__(self, fdatagrid, method=modified_band_depth, factor=1.5): raise ValueError( "Class only supports FDataGrid with domain dimension 2.") - lx = len(fdatagrid.sample_points[0]) - ly = len(fdatagrid.sample_points[1]) - - self._median = np.ndarray((fdatagrid.ndim_image, lx, ly)) - self._central_envelope = np.ndarray((fdatagrid.ndim_image, 2, lx, ly)) - self._outlying_envelope = np.ndarray((fdatagrid.ndim_image, 2, lx, ly)) - depth = method(fdatagrid) - indices_descencing_depth = (-depth).argsort(axis=0) - - for m in range(fdatagrid.ndim_image): - indices_samples = indices_descencing_depth[:, m][ - :math.ceil(fdatagrid.nsamples * 0.5)] - samples_used = fdatagrid.data_matrix[indices_samples, :, :, m] - max_samples_used = np.amax(samples_used, axis=0) - min_samples_used = np.amin(samples_used, axis=0) + indices_descending_depth = (-depth).argsort(axis=0) - # mean sample - self._median[m] = fdatagrid.data_matrix[ - indices_descencing_depth[0, m], :, :, m] + # The mean is the deepest curve + self._median = fdatagrid.data_matrix[indices_descending_depth[0]] - # central envelope - self._central_envelope[m] = np.asarray([max_samples_used, - min_samples_used]) + # Central region and envelope must be computed for outlier detection + central_region = _envelopes._compute_region( + fdatagrid, indices_descending_depth, 0.5) + self._central_envelope = _envelopes._compute_envelope(central_region) - # outlying envelope - max_value = np.amax(fdatagrid.data_matrix[:, :, :, m], axis=0) - min_value = np.amin(fdatagrid.data_matrix[:, :, :, m], axis=0) - iqr = np.absolute(max_samples_used - min_samples_used) - oulying_max_envelope = np.minimum(max_samples_used + iqr * factor, - max_value) - oulying_min_envelope = np.maximum(min_samples_used - iqr * factor, - min_value) - self._outlying_envelope[m] = np.asarray([oulying_max_envelope, - oulying_min_envelope]) + # Non-outlying envelope + non_outlying_threshold = _envelopes._non_outlying_threshold( + self._central_envelope, factor) + predicted_outliers = _envelopes._predict_outliers( + fdatagrid, non_outlying_threshold) + inliers = fdatagrid[predicted_outliers == 0] + self._non_outlying_envelope = _envelopes._compute_envelope(inliers) self._fdatagrid = fdatagrid self.colormap = plt.cm.get_cmap('Greys') @@ -584,8 +578,8 @@ def central_envelope(self): return self._central_envelope @property - def outlying_envelope(self): - return self._outlying_envelope + def non_outlying_envelope(self): + return self._non_outlying_envelope @property def boxcol(self): @@ -645,67 +639,77 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): for m in range(self.fdatagrid.ndim_image): # mean sample - ax[m].plot_wireframe(X, Y, np.squeeze(self.median[m]).T, + ax[m].plot_wireframe(X, Y, np.squeeze(self.median[..., m]).T, rstride=ly, cstride=lx, color=self.colormap(self.boxcol)) - ax[m].plot_surface(X, Y, np.squeeze(self.median[m]).T, + ax[m].plot_surface(X, Y, np.squeeze(self.median[..., m]).T, color=self.colormap(self.boxcol), alpha=0.8) # central envelope - ax[m].plot_surface(X, Y, np.squeeze(self.central_envelope[m, 0]).T, - color=self.colormap(self.boxcol), alpha=0.5) - ax[m].plot_wireframe(X, Y, - np.squeeze(self.central_envelope[m, 0]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.boxcol)) - ax[m].plot_surface(X, Y, np.squeeze(self.central_envelope[m, 1]).T, - color=self.colormap(self.boxcol), alpha=0.5) - ax[m].plot_wireframe(X, Y, - np.squeeze(self.central_envelope[m, 1]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.boxcol)) + ax[m].plot_surface( + X, Y, np.squeeze(self.central_envelope[0][..., m]).T, + color=self.colormap(self.boxcol), alpha=0.5) + ax[m].plot_wireframe( + X, Y, np.squeeze(self.central_envelope[0][..., m]).T, + rstride=ly, cstride=lx, + color=self.colormap(self.boxcol)) + ax[m].plot_surface( + X, Y, np.squeeze(self.central_envelope[1][..., m]).T, + color=self.colormap(self.boxcol), alpha=0.5) + ax[m].plot_wireframe( + X, Y, np.squeeze(self.central_envelope[1][..., m]).T, + rstride=ly, cstride=lx, + color=self.colormap(self.boxcol)) # box vertical lines for indices in [(0, 0), (0, ly - 1), (lx - 1, 0), (lx - 1, ly - 1)]: x_corner = x[indices[0]] y_corner = y[indices[1]] - ax[m].plot([x_corner, x_corner], [y_corner, y_corner], - [self.central_envelope[ - m, 1, indices[0], indices[1]], - self.central_envelope[ - m, 0, indices[0], indices[1]]], - color=self.colormap(self.boxcol)) + ax[m].plot( + [x_corner, x_corner], [y_corner, y_corner], + [ + self.central_envelope[1][..., m][indices[0], + indices[1]], + self.central_envelope[0][..., m][indices[0], + indices[1]]], + color=self.colormap(self.boxcol)) # outlying envelope - ax[m].plot_surface(X, Y, - np.squeeze(self.outlying_envelope[m, 0]).T, - color=self.colormap(self.outcol), alpha=0.3) - ax[m].plot_wireframe(X, Y, - np.squeeze(self.outlying_envelope[m, 0]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.outcol)) - ax[m].plot_surface(X, Y, - np.squeeze(self.outlying_envelope[m, 1]).T, - color=self.colormap(self.outcol), alpha=0.3) - ax[m].plot_wireframe(X, Y, - np.squeeze(self.outlying_envelope[m, 1]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.outcol)) + ax[m].plot_surface( + X, Y, + np.squeeze(self.non_outlying_envelope[0][..., m]).T, + color=self.colormap(self.outcol), alpha=0.3) + ax[m].plot_wireframe( + X, Y, + np.squeeze(self.non_outlying_envelope[0][..., m]).T, + rstride=ly, cstride=lx, + color=self.colormap(self.outcol)) + ax[m].plot_surface( + X, Y, + np.squeeze(self.non_outlying_envelope[1][..., m]).T, + color=self.colormap(self.outcol), alpha=0.3) + ax[m].plot_wireframe( + X, Y, + np.squeeze(self.non_outlying_envelope[1][..., m]).T, + rstride=ly, cstride=lx, + color=self.colormap(self.outcol)) # vertical lines from central to outlying envelope x_index = math.floor(lx / 2) x_central = x[x_index] y_index = math.floor(ly / 2) y_central = y[y_index] - ax[m].plot([x_central, x_central], [y_central, y_central], - [self.outlying_envelope[m, 1, x_index, y_index], - self.central_envelope[m, 1, x_index, y_index]], - color=self.colormap(self.boxcol)) - ax[m].plot([x_central, x_central], [y_central, y_central], - [self.outlying_envelope[m, 0, x_index, y_index], - self.central_envelope[m, 0, x_index, y_index]], - color=self.colormap(self.boxcol)) + ax[m].plot( + [x_central, x_central], [y_central, y_central], + [self.non_outlying_envelope[1][..., m][x_index, y_index], + self.central_envelope[1][..., m][x_index, y_index]], + color=self.colormap(self.boxcol)) + ax[m].plot( + [x_central, x_central], [y_central, y_central], + [self.non_outlying_envelope[0][..., m][x_index, y_index], + self.central_envelope[0][..., m][x_index, y_index]], + color=self.colormap(self.boxcol)) self.fdatagrid.set_labels(fig, ax) @@ -717,5 +721,5 @@ def __repr__(self): f"\nFDataGrid={repr(self.fdatagrid)}," f"\nmedian={repr(self.median)}," f"\ncentral envelope={repr(self.central_envelope)}," - f"\noutlying envelope={repr(self.outlying_envelope)})") + f"\noutlying envelope={repr(self.non_outlying_envelope)})") .replace('\n', '\n ')) diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index e9a3f2797..5f44e602d 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -1124,7 +1124,8 @@ def __getitem__(self, key): return self.copy(data_matrix=self.data_matrix[key], sample_points=sample_points) - if isinstance(key, int): + if isinstance(key, numbers.Integral): # To accept also numpy ints + key = int(key) return self.copy(data_matrix=self.data_matrix[key:key + 1]) else: diff --git a/tests/test_depth.py b/tests/test_depth.py deleted file mode 100644 index 48e5e79a1..000000000 --- a/tests/test_depth.py +++ /dev/null @@ -1,93 +0,0 @@ -import unittest -import numpy as np - -from skfda import FDataGrid -from skfda.exploratory.depth import band_depth, modified_band_depth, fraiman_muniz_depth - - -class TestDepthMeasures(unittest.TestCase): - - # def setUp(self): could be defined for set up before any test - - def test_band_depth_univariate(self): - data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] - sample_points = [0, 2, 4, 6, 8, 10] - fd = FDataGrid(data_matrix, sample_points) - depth = band_depth(fd) - np.testing.assert_allclose(depth, np.array([[0.5], [0.83333333], [0.5], [0.5]])) - - def test_band_depth_multivariate(self): - data_matrix = [[[[1, 4], [0.3, 1.5], [1, 3]], [[2, 8], [0.4, 2], [2, 9]]], - [[[2, 10], [0.5, 3], [2, 10]], [[3, 12], [0.6, 3], [3, 15]]]] - sample_points = [[2, 4], [3, 6, 8]] - fd = FDataGrid(data_matrix, sample_points) - depth = band_depth(fd) - np.testing.assert_array_equal(depth, np.array([[1., 1.], [1., 1.]])) - - def test_modified_band_depth_univariate(self): - data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] - sample_points = [0, 2, 4, 6, 8, 10] - fd = FDataGrid(data_matrix, sample_points) - depth = modified_band_depth(fd, pointwise=True) - sample_depth = depth[0] - pointwise_sample_depth = depth[1] - np.testing.assert_allclose(sample_depth, - np.array([[0.5], [0.83333333], [0.72222222], [0.66666667]])) - np.testing.assert_allclose(pointwise_sample_depth, - np.array([[[0.5], [0.5], [0.5], - [0.5], [0.5], [0.5]], - [[0.83333333], [0.83333333], [0.83333333], - [0.83333333], [0.83333333], [0.83333333]], - [[0.5], [0.5], [0.83333333], - [0.83333333], [0.83333333], [0.83333333]], - [[0.83333333], [0.83333333], [0.83333333], - [0.5], [0.5], [0.5]]])) - - def test_modified_band_depth_multivariate(self): - data_matrix = [[[[1, 4], [0.3, 1.5], [1, 3]], [[2, 8], [0.4, 2], [2, 9]]], - [[[2, 10], [0.5, 3], [2, 10]], [[3, 12], [0.6, 3], [3, 15]]]] - sample_points = [[2, 4], [3, 6, 8]] - fd = FDataGrid(data_matrix, sample_points) - depth = modified_band_depth(fd, pointwise=True) - sample_depth = depth[0] - pointwise_sample_depth = depth[1] - np.testing.assert_array_equal(sample_depth, np.array([[1., 1.], [1., 1.]])) - np.testing.assert_array_equal(pointwise_sample_depth, - np.array([[[[1., 1.], [1., 1.], [1., 1.]], - [[1., 1.], [1., 1.], [1., 1.]]], - [[[1., 1.], [1., 1.], [1., 1.]], - [[1., 1.], [1., 1.], [1., 1.]]]])) - - def test_fraiman_muniz_band_depth_univariate(self): - data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], - [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] - sample_points = [0, 2, 4, 6, 8, 10] - fd = FDataGrid(data_matrix, sample_points) - sample_depth, pointwise_sample_depth = fraiman_muniz_depth(fd, pointwise=True) - np.testing.assert_allclose(sample_depth, - np.array([[0.5], [0.75], [0.91666667], [0.875]])) - np.testing.assert_array_equal(pointwise_sample_depth, - np.array([[[0.5], [0.5], [0.5], [0.5], [0.5], [0.5]], - [[0.75], [0.75], [0.75], [0.75], [0.75], [0.75]], - [[0.75], [0.75], [1.], [1.], [1.], [1.]], - [[1.], [1.], [1.], [0.75], [0.75], [0.75]]])) - - def test_fraiman_muniz_depth_multivariate(self): - data_matrix = [[[[1, 4], [0.3, 1.5], [1, 3]], [[2, 8], [0.4, 2], [2, 9]]], - [[[2, 10], [0.5, 3], [2, 10]], [[3, 12], [0.6, 3], [3, 15]]]] - sample_points = [[2, 4], [3, 6, 8]] - fd = FDataGrid(data_matrix, sample_points) - sample_depth, pointwise_sample_depth = fraiman_muniz_depth(fd, pointwise=True) - np.testing.assert_array_equal(sample_depth, np.array([[1., 1.], [0.5, 0.5]])) - np.testing.assert_allclose(pointwise_sample_depth, - np.array([[[[1., 1.], [1., 1.], [1., 1.]], - [[1., 1.], [1., 1.], [1., 1.]]], - [[[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]], - [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]]]])) - - -if __name__ == '__main__': - print() - unittest.main() diff --git a/tests/test_fdata_boxplot.py b/tests/test_fdata_boxplot.py index 0bb7bed85..4aaac2eab 100644 --- a/tests/test_fdata_boxplot.py +++ b/tests/test_fdata_boxplot.py @@ -1,92 +1,43 @@ import unittest -import numpy as np +import matplotlib.pyplot as plt +import numpy as np from skfda import FDataGrid from skfda.exploratory.depth import band_depth, fraiman_muniz_depth from skfda.exploratory.visualization.boxplot import Boxplot, SurfaceBoxplot -import matplotlib.pyplot as plt class TestBoxplot(unittest.TestCase): - # def setUp(self): could be defined for set up before any test - - def test_fdboxplot_multivariate(self): - data_matrix = [[[1, 0.3], [2, 0.4], [3, 0.5], [4, 0.6]], - [[2, 0.5], [3, 0.6], [4, 0.7], [5, 0.7]], - [[3, 0.2], [4, 0.3], [5, 0.4], [6, 0.5]]] - sample_points = [2, 4, 6, 8] - fd = FDataGrid(data_matrix, sample_points) - fdataBoxplot = Boxplot(fd, prob=[0.75, 0.5, 0.25]) - np.testing.assert_array_equal(fdataBoxplot.median, - np.array([[2., 3., 4., 5.], - [0.3, 0.4, 0.5, 0.6]])) - np.testing.assert_array_equal(fdataBoxplot.central_envelope, np.array( - [[[2., 3., 4., 5.], [1., 2., 3., 4.]], - [[0.5, 0.6, 0.7, 0.7], - [0.3, 0.4, 0.5, 0.6]]])) - np.testing.assert_array_equal(fdataBoxplot.outlying_envelope, np.array( - [[[3., 4., 5., 6.], [1., 2., 3., 4.]], - [[0.5, 0.6, 0.7, 0.7], - [0.2, 0.3, 0.4, 0.5]]])) - np.testing.assert_array_equal(fdataBoxplot.central_regions, np.array( - [[[3., 4., 5., 6.], [1., 2., 3., 4.]], - [[2., 3., 4., 5.], [1., 2., 3., 4.]], - [[2., 3., 4., 5.], [2., 3., 4., 5.]], - [[0.5, 0.6, 0.7, 0.7], - [0.2, 0.3, 0.4, 0.5]], - [[0.5, 0.6, 0.7, 0.7], - [0.3, 0.4, 0.5, 0.6]], - [[0.3, 0.4, 0.5, 0.6], - [0.3, 0.4, 0.5, 0.6]]])) - np.testing.assert_array_equal(fdataBoxplot.outliers, - np.array([[0., 0., 0.], - [0., 0., 0.]])) - def test_fdboxplot_univariate(self): - data_matrix = [[1, 1, 2, 3, 2.5, 2], [0.5, 0.5, 1, 2, 1.5, 1], + data_matrix = [[1, 1, 2, 3, 2.5, 2], + [0.5, 0.5, 1, 2, 1.5, 1], [-1, -1, -0.5, 1, 1, 0.5], [-0.5, -0.5, -0.5, -1, -1, -1]] sample_points = [0, 2, 4, 6, 8, 10] fd = FDataGrid(data_matrix, sample_points) fdataBoxplot = Boxplot(fd, method=fraiman_muniz_depth) - np.testing.assert_array_equal(fdataBoxplot.median, np.array( - [[-1., -1., -0.5, 1., 1., 0.5]])) - np.testing.assert_array_equal(fdataBoxplot.central_envelope, np.array( - [[[-0.5, -0.5, -0.5, 1., 1., 0.5], - [-1., -1., -0.5, -1., -1., -1.]]])) - np.testing.assert_array_equal(fdataBoxplot.outlying_envelope, np.array( - [[[0.25, 0.25, -0.5, 3., 2.5, 2.], - [-1., -1., -0.5, -1., -1., -1.]]])) - np.testing.assert_array_equal(fdataBoxplot.central_regions, np.array( - [[[-0.5, -0.5, -0.5, 1., 1., 0.5], - [-1., -1., -0.5, -1., -1., -1.]]])) + np.testing.assert_array_equal( + fdataBoxplot.median.ravel(), + np.array([-1., -1., -0.5, 1., 1., 0.5])) + np.testing.assert_array_equal( + fdataBoxplot.central_envelope[0].ravel(), + np.array([-1., -1., -0.5, -1., -1., -1.])) + np.testing.assert_array_equal( + fdataBoxplot.central_envelope[1].ravel(), + np.array([-0.5, -0.5, -0.5, 1., 1., 0.5])) + np.testing.assert_array_equal( + fdataBoxplot.non_outlying_envelope[0].ravel(), + np.array([-1., -1., -0.5, -1., -1., -1.])) + np.testing.assert_array_equal( + fdataBoxplot.non_outlying_envelope[1].ravel(), + np.array([-0.5, -0.5, -0.5, 1., 1., 0.5])) + self.assertEqual(len(fdataBoxplot.envelopes), 1) + np.testing.assert_array_equal( + fdataBoxplot.envelopes[0], + fdataBoxplot.central_envelope) np.testing.assert_array_equal(fdataBoxplot.outliers, - np.array([[1., 1., 0., 0.]])) - - def test_surface_boxplot(self): - data_matrix = [ - [[[1, 4], [0.3, 1.5], [1, 3]], [[2, 8], [0.4, 2], [2, 9]]], - [[[2, 10], [0.5, 3], [2, 10]], [[3, 12], [0.6, 3], [3, 15]]], - [[[5, 8], [5, 2], [1, 6]], [[5, 20], [0.3, 5], [7, 1]]]] - sample_points = [[2, 4], [3, 6, 8]] - fd = FDataGrid(data_matrix, sample_points) - plt.figure() - fdataBoxplot = SurfaceBoxplot(fd, method=band_depth) - np.testing.assert_array_equal(fdataBoxplot.median, - np.array([[[1., 0.3, 1.], [2., 0.4, 2.]], - [[4., 1.5, 3.], - [8., 2., 9.]]])) - np.testing.assert_array_equal(fdataBoxplot.central_envelope, np.array( - [[[[2., 0.5, 2.], [3., 0.6, 3.]], - [[1., 0.3, 1.], [2., 0.4, 2.]]], - [[[10., 3., 10.], [12., 3., 15.]], - [[4., 1.5, 3.], [8., 2., 9.]]]])) - np.testing.assert_array_equal(fdataBoxplot.outlying_envelope, np.array( - [[[[3.5, 0.8, 2.], [4.5, 0.6, 4.5]], - [[1., 0.3, 1.], [2., 0.3, 2.]]], - [[[10., 3., 10.], [18., 4.5, 15.]], - [[4., 1.5, 3.], [8., 2., 1.]]]])) + np.array([True, True, False, False])) if __name__ == '__main__': From 37f375b7b017e90321b5f0c940a8188cacb387ef Mon Sep 17 00:00:00 2001 From: pablomm Date: Wed, 7 Aug 2019 17:38:33 +0200 Subject: [PATCH 162/222] Pull request changes --- examples/plot_k_neighbors_classification.py | 59 +++++----- examples/plot_neighbors_scalar_regression.py | 49 +++----- .../plot_radius_neighbors_classification.py | 58 +++++----- skfda/_neighbors/base.py | 9 +- skfda/_neighbors/classification.py | 2 +- skfda/_neighbors/outliers.py | 0 skfda/_neighbors/regression.py | 6 +- skfda/misc/metrics.py | 73 ++++++------ tests/test_metrics.py | 109 ++++++++++++++++++ 9 files changed, 234 insertions(+), 131 deletions(-) delete mode 100644 skfda/_neighbors/outliers.py create mode 100644 tests/test_metrics.py diff --git a/examples/plot_k_neighbors_classification.py b/examples/plot_k_neighbors_classification.py index 0774af372..4b1374da2 100644 --- a/examples/plot_k_neighbors_classification.py +++ b/examples/plot_k_neighbors_classification.py @@ -11,7 +11,8 @@ import skfda import numpy as np import matplotlib.pyplot as plt -from sklearn.model_selection import train_test_split, GridSearchCV, KFold +from sklearn.model_selection import (train_test_split, GridSearchCV, + StratifiedShuffleSplit) from skfda.ml.classification import KNeighborsClassifier @@ -21,21 +22,24 @@ # classifier in their functional version, which is a extension of the # multivariate one, but using functional metrics between the observations. # -# Firstly, we are going to fetch a functional data dataset, such as the Berkeley -# Growth Study. This dataset correspond to the height of several boys and girls +# Firstly, we are going to fetch a functional dataset, such as the Berkeley +# Growth Study. This dataset contains the height of several boys and girls # measured until the 18 years of age. -# # We will try to predict the sex by using its growth curves. # # The following figure shows the growth curves grouped by sex. # +# Loads dataset data = skfda.datasets.fetch_growth() X = data['data'] y = data['target'] +class_names = data['target_names'] + +# Plot samples grouped by sex +plt.figure() +X.plot(sample_labels=y, label_names=class_names, label_colors=['C0', 'C1']) -X[y==0].plot(color='C0') -X[y==1].plot(color='C1') ################################################################################ # @@ -50,15 +54,13 @@ # We can split the dataset using the sklearn function # :func:`train_test_split `. # -# We will use two thirds of the dataset for the training partition and the -# remaining samples for testing. -# # The function will return two :class:`FDataGrid `'s, # ``X_train`` and ``X_test`` with the corresponding partitions, and arrays # with their class labels. # -X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, random_state=0) +X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, + stratify=y, random_state=0) ################################################################################ @@ -83,7 +85,6 @@ # k-nearest neighbors and will asign the majority class. By default, it is # used the :math:`\mathbb{L}^2` distance between functions, to determine the # neighbourhood of a sample, with 5 neighbors. -# # Can be used any of the functional metrics of the module # :mod:`skfda.misc.metrics`. # @@ -106,7 +107,7 @@ # using :func:`predict_proba`, which will return an array with the # probabilities of the classes, in lexicographic order, for each test sample. -probs = knn.predict_proba(X_test[:5]) +probs = knn.predict_proba(X_test[:5]) # Predict first 5 samples print(probs) @@ -124,7 +125,10 @@ knn = KNeighborsClassifier() -gscv = GridSearchCV(knn, param_grid, cv=KFold(shuffle=True, random_state=0)) + +# Perform grid search with cross-validation +ss = StratifiedShuffleSplit(n_splits=5, test_size=.25, random_state=0) +gscv = GridSearchCV(knn, param_grid, cv=ss) gscv.fit(X, y) @@ -134,13 +138,12 @@ ################################################################################ # -# We have obtained the greatest mean accuracy using 3 neighbors. The following +# We have obtained the greatest mean accuracy using 11 neighbors. The following # figure shows the score depending on the number of neighbors. # plt.figure() plt.bar(param_grid['n_neighbors'], gscv.cv_results_['mean_test_score']) - plt.xticks(param_grid['n_neighbors']) plt.ylabel("Number of Neighbors") plt.xlabel("Test score") @@ -149,10 +152,14 @@ ################################################################################ # -# In this dataset, the functional observations have been sampled equiespaciated. -# If we approximate the integral of the :math:`\mathbb{L}^2` distance as a -# Riemann sum (actually the Simpson's rule it is used), we obtain that -# it is approximately equivalent to the euclidean distance between vectors. +# When the functional data have been sampled in an equiespaced way, or +# approximately equiespaced, it is possible to use the scikit-learn vector +# metrics with similar results. +# +# For example, in the case of the :math:`\mathbb{L}^2` distance, +# if the integral of the distance it is approximated as a +# Riemann sum, we obtain that it is proporitonal to the euclidean +# distance between vectors. # # .. math:: # \|f - g \|_{\mathbb{L}^2} = \left ( \int_a^b |f(x) - g(x)|^2 dx \right ) @@ -176,7 +183,7 @@ # knn = KNeighborsClassifier(metric='euclidean', sklearn_metric=True) -gscv2 = GridSearchCV(knn, param_grid, cv=KFold(shuffle=True, random_state=0)) +gscv2 = GridSearchCV(knn, param_grid, cv=ss) gscv2.fit(X, y) print("Best params:", gscv2.best_params_) @@ -185,16 +192,16 @@ ################################################################################ # # The advantage of use the sklearn metrics is the computational speed, three -# orders of magnitude faster. But it is not always possible to resample samples -# equiespaced nor do all functional metrics have a vector equivalent in this -# way. +# orders of magnitude faster. But it is not always possible to have +# equiespaced samples nor do all functional metrics have a vector equivalent +# in this way. # # The mean score time depending on the metric is shown below. # -print("Mean score time (seconds)") -print("L2 distance:", np.mean(gscv.cv_results_['mean_score_time']), "(s)") -print("Sklearn distance:", np.mean(gscv2.cv_results_['mean_score_time']), "(s)") +print("Mean score time (milliseconds)") +print("L2 distance:", 1000*np.mean(gscv.cv_results_['mean_score_time']), "(ms)") +print("Euclidean distance:", 1000*np.mean(gscv2.cv_results_['mean_score_time']), "(ms)") ################################################################################ # diff --git a/examples/plot_neighbors_scalar_regression.py b/examples/plot_neighbors_scalar_regression.py index 447856be3..f80eb30d1 100644 --- a/examples/plot_neighbors_scalar_regression.py +++ b/examples/plot_neighbors_scalar_regression.py @@ -15,7 +15,6 @@ import numpy as np from sklearn.model_selection import train_test_split, GridSearchCV, KFold from skfda.ml.regression import KNeighborsScalarRegressor -from skfda.misc.metrics import norm_lp ################################################################################ @@ -29,48 +28,35 @@ # # Firstly we will fetch a dataset to show the basic usage. # -# The caniadian weather dataset contains the daily temperature and precipitation -# at 35 different locations in Canada averaged over 1960 to 1994. +# The caniadian weather dataset contains the daily temperature and +# precipitation at 35 different locations in Canada averaged over 1960 to 1994. # -# The following figure shows the different temperature curves. +# The following figure shows the different temperature and precipitation +# curves. # data = skfda.datasets.fetch_weather() fd = data['data'] -# TODO: Change this after merge operations-with-images -fd.axes_labels = None -X = fd.copy(data_matrix=fd.data_matrix[..., 0]) +# Split dataset, temperatures and curves of precipitation +X, y_func = fd.coordinates +plt.figure() X.plot() - -################################################################################ -# -# In this example we are not interested in the precipitation curves directly, -# as in the case with regression response, we will train a nearest neighbor -# regressor to predict a scalar magnitude. -# -# In the next figure the precipitation curves are shown. -# - -y_func = fd.copy(data_matrix=fd.data_matrix[..., 1]) - plt.figure() y_func.plot() ################################################################################ # # We will try to predict the total log precipitation, i.e, -# :math:`logPrecTot_i = \log \int_0^{365} prec_i(t)dt` using the temperature +# :math:`logPrecTot_i = \log \sum_{t=0}^{365} prec_i(t)` using the temperature # curves. # -# To obtain the precTot we will calculate the :math:`\mathbb{L}^1` norm of -# the precipitation curves. -# -prec = norm_lp(y_func, 1) +# Sum directly from the data matrix +prec = y_func.data_matrix.sum(axis=1)[:,0] log_prec = np.log(prec) print(log_prec) @@ -82,12 +68,13 @@ # :func:`sklearn.model_selection.train_test_split`. # -X_train, X_test, y_train, y_test = train_test_split(X, log_prec, random_state=7) +X_train, X_test, y_train, y_test = train_test_split(X, log_prec, + random_state=7) ################################################################################ # # Firstly we will try make a prediction with the default values of the -# estimator, using 5 neighbors and the :math:`\mathbb{L}^2`. +# estimator, using 5 neighbors and the :math:`\mathbb{L}^2` distance. # # We can fit the :class:`KNeighborsScalarRegressor # ` in the same way than the @@ -162,17 +149,17 @@ knn = KNeighborsScalarRegressor(metric='euclidean', sklearn_metric=True) -gscv = GridSearchCV(knn, param_grid, cv=KFold(shuffle=True, random_state=0)) +gscv = GridSearchCV(knn, param_grid, cv=KFold(n_splits=3, + shuffle=True, random_state=0)) gscv.fit(X, log_prec) ################################################################################ # -# We obtain that 7 is the optimal number of neighbors, and a lower value of the -# :math:`R^2` coefficient, but much closer to the real one. +# We obtain that 7 is the optimal number of neighbors. # -print(gscv.best_params_) -print(gscv.best_score_) +print("Best params", gscv.best_params_) +print("Best score", gscv.best_score_) ################################################################################ # diff --git a/examples/plot_radius_neighbors_classification.py b/examples/plot_radius_neighbors_classification.py index 6f35f6be7..6997c17e2 100644 --- a/examples/plot_radius_neighbors_classification.py +++ b/examples/plot_radius_neighbors_classification.py @@ -27,18 +27,22 @@ # radius, instead of use the k nearest neighbors. # # Firstly, we will construct a toy dataset to show the basic usage of the API. -# -# We will create two classes of sinusoidal samples, with different locations -# of their phase. +# We will create two classes of sinusoidal samples, with different phases. # +# Make toy dataset fd1 = skfda.datasets.make_sinusoidal_process(error_std=.0, phase_std=.35, random_state=0) fd2 = skfda.datasets.make_sinusoidal_process(phase_mean=1.9, error_std=.0, random_state=1) -fd1.plot(color='C0') -fd2.plot(color='C1') +X = fd1.concatenate(fd2) +y = np.array(15*[0] + 15*[1]) + +# Plot toy dataset +plt.figure() +X.plot(sample_labels=y, label_colors=['C0', 'C1']) + ################################################################################ @@ -48,38 +52,35 @@ # :func:`sklearn.model_selection.train_test_split`. # Concatenate the two classes in the same FDataGrid -X = fd1.concatenate(fd2) -y = np.array(15*[0] + 15*[1]) -X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.33, - shuffle=True, random_state=0) + +X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, + shuffle=True, stratify=y, + random_state=0) ################################################################################ # -# As in the multivariate data, the label assigned to a test sample will be the +# The label assigned to a test sample will be the # majority class of its neighbors, in this case all the samples in the ball # center in the sample. # # If we use the :math:`\mathbb{L}^\infty` metric, we can visualize a ball # as a bandwidth with a fixed radius around a function. -# # The following figure shows the ball centered in the first sample of the test # partition. # +radius = 0.3 +sample = X_test[0] # Center of the ball plt.figure() +X_train.plot(sample_labels=y_train, label_colors=['C0', 'C1']) -sample = X_test[0] - -X_train[y_train == 0].plot(color='C0') -X_train[y_train == 1].plot(color='C1') +# Plot ball sample.plot(color='red', linewidth=3) - -lower = sample - 0.3 -upper = sample + 0.3 - +lower = sample - radius +upper = sample + radius plt.fill_between(sample.sample_points[0], lower.data_matrix.flatten(), upper.data_matrix[0].flatten(), alpha=.25, color='C1') @@ -95,13 +96,12 @@ l_inf = pairwise_distance(lp_distance, p=np.inf) distances = l_inf(sample, X_train)[0] # L_inf distances to 'sample' +# Plot samples in the ball plt.figure() - -X_train[distances <= .3].plot(color='C0') +X_train[distances <= radius].plot(color='C0') sample.plot(color='red', linewidth=3) - plt.fill_between(sample.sample_points[0], lower.data_matrix.flatten(), - upper.data_matrix[0].flatten(), alpha=.25, color='C1') + upper.data_matrix[0].flatten(), alpha=.25, color='C1') ################################################################################ @@ -115,7 +115,7 @@ # In this case we will weight the vote inversely proportional to the distance. # -radius_nn = RadiusNeighborsClassifier(radius=.3, weights='distance') +radius_nn = RadiusNeighborsClassifier(radius=radius, weights='distance') radius_nn.fit(X_train, y_train) @@ -129,8 +129,7 @@ ################################################################################ # -# In this case, we get 100% accuracy, althouth, it is a toy dataset and it does -# not have much merit. +# In this case, we get 100% accuracy, althouth, it is a toy dataset. # test_score = radius_nn.score(X_test, y_test) @@ -138,7 +137,7 @@ ################################################################################ # -# As in the K-nearest neighbor example, we can use a sklearn metric +# As in the K-nearest neighbor example, we can use the euclidean sklearn metric # approximately equivalent to the functional :math:`\mathbb{L}^2` one, # but computationally faster. # @@ -150,7 +149,7 @@ # :math:`\sqrt{\bigtriangleup h}`. # # In this dataset :math:`\bigtriangleup h=0.001`, so, we have to multiply the -# radius by :math:`10` to achieve the same result. +# radius by :math:`\frac{1}{\bigtriangleup h}=10` to achieve the same result. # # The computation using this metric it is 1000 times faster. See the # K-neighbors classifier example and the API documentation to get detailled @@ -164,10 +163,9 @@ radius_nn.fit(X_train, y_train) - test_score = radius_nn.score(X_test, y_test) -print(test_score) +print(test_score) ################################################################################ # diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 6048ee7cc..ab607f6f7 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -55,9 +55,6 @@ def _to_sklearn_metric(metric, sample_points): two FDataGrids and return a float representing the distance. sample_points (array_like): Array of arrays with the sample points of the FDataGrids. - check (boolean, optional): If False it is passed the named parameter - `check=False` to avoid the repetition of checks in internal - routines. Returns: (pyfunc): sklearn vector metric. @@ -87,11 +84,11 @@ def _to_sklearn_metric(metric, sample_points): # Shape -> (Nsamples = 1, domain_dims...., image_dimension (-1)) shape = [1] + [len(axis) for axis in sample_points] + [-1] - def sklearn_metric(x, y, check=True, **kwargs): + def sklearn_metric(x, y, _check=False, **kwargs): return metric(_from_multivariate(x, sample_points, shape), _from_multivariate(y, sample_points, shape), - check=check, **kwargs) + _check=_check, **kwargs) return sklearn_metric @@ -608,7 +605,7 @@ def score(self, X, y, sample_weight=None): .. math:: 1 - \frac{\sum_{i=1}^{n}\int (y_i(t) - \hat{y}_i(t))^2dt} - {\sum_{i=1}^{n} \int (y_i(t) - \frac{1}{n}\sum_{i=1}^{n}y_i(t))^2dt} + {\sum_{i=1}^{n} \int (y_i(t)- \frac{1}{n}\sum_{i=1}^{n}y_i(t))^2dt} where :math:`\hat{y}_i` is the prediction associated to the test sample :math:`X_i`, and :math:`{y}_i` is the true response. diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 4d871e0f3..386491131 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -170,7 +170,7 @@ def predict_proba(self, X): class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, RadiusNeighborsMixin, ClassifierMixin, NeighborsClassifierMixin): - """Classifier implementing a vote among neighbors within a given radius + """Classifier implementing a vote among neighbors within a given radius. Parameters ---------- diff --git a/skfda/_neighbors/outliers.py b/skfda/_neighbors/outliers.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index f3910f1bb..9cb59d886 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -335,7 +335,8 @@ class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, and we will try to predict 5 X +1. >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.05, random_state=0) + >>> X_train = make_multimodal_samples(n_samples=30, std=.05, + ... random_state=0) >>> y_train = 5 * X_train + 1 >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) @@ -455,7 +456,8 @@ class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, and we will try to predict the response 5 X +1. >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.05, random_state=0) + >>> X_train = make_multimodal_samples(n_samples=30, std=.05, + ... random_state=0) >>> y_train = 5 * X_train + 1 >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 59d29f834..5f40d0d7e 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -9,8 +9,8 @@ elastic_registration_warping) -def _cast_to_grid(fdata1, fdata2, eval_points=None, check=True, **kwargs): - """Checks if the fdatas passed as argument are unidimensional and +def _cast_to_grid(fdata1, fdata2, eval_points=None, _check=True, **kwargs): + """Checks if the fdatas passed as argument are unidimensional and compatible and converts them to FDatagrid to compute their distances. Args: @@ -22,16 +22,9 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None, check=True, **kwargs): """ # Dont perform any check - if not check: + if not _check: return fdata1, fdata2 - # To allow use numpy arrays internally - if (not isinstance(fdata1, FData) and not isinstance(fdata2, FData) - and eval_points is not None): - fdata1 = FDataGrid([fdata1], sample_points=eval_points) - fdata2 = FDataGrid([fdata1], sample_points=eval_points) - - # Checks dimension elif (fdata2.ndim_image != fdata1.ndim_image or fdata2.ndim_domain != fdata1.ndim_domain): raise ValueError("Objects should have the same dimensions") @@ -42,21 +35,21 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None, check=True, **kwargs): # Case new evaluation points specified elif eval_points is not None: - if not np.array_equal(eval_points, fdata1.sample_points[0]): - fdata1 = fdata1.to_grid(eval_points) - if not np.array_equal(eval_points, fdata2.sample_points[0]): - fdata2 = fdata2.to_grid(eval_points) + fdata1 = fdata1.to_grid(eval_points) + fdata2 = fdata2.to_grid(eval_points) elif not isinstance(fdata1, FDataGrid) and isinstance(fdata2, FDataGrid): - fdata1 = fdata1.to_grid(fdata2.eval_points) + fdata1 = fdata1.to_grid(fdata2.sample_points[0]) elif not isinstance(fdata2, FDataGrid) and isinstance(fdata1, FDataGrid): - fdata2 = fdata2.to_grid(fdata1.eval_points) + fdata2 = fdata2.to_grid(fdata1.sample_points[0]) elif (not isinstance(fdata1, FDataGrid) and not isinstance(fdata2, FDataGrid)): - fdata1 = fdata1.to_grid(eval_points) - fdata2 = fdata2.to_grid(eval_points) + domain = fdata1.domain_range[0] + sample_points = np.linspace(*domain) + fdata1 = fdata1.to_grid(sample_points) + fdata2 = fdata2.to_grid(sample_points) elif not np.array_equal(fdata1.sample_points, fdata2.sample_points): @@ -118,7 +111,7 @@ def vectorial_norm(fdatagrid, p=2): if p == 'inf': - p == np.inf + p = np.inf data_matrix = np.linalg.norm(fdatagrid.data_matrix, ord=p, axis=-1, keepdims=True) @@ -208,7 +201,7 @@ def pairwise(fdata1, fdata2): # Iterates over the different samples of both objects. for i in range(fdata1.nsamples): for j in range(fdata2.nsamples): - matrix[i, j] = distance(fdata1[i], fdata2[j], check=False, + matrix[i, j] = distance(fdata1[i], fdata2[j], _check=False, **kwargs) # Computes the metric between all piars of x and y. return matrix @@ -285,10 +278,12 @@ def norm_lp(fdatagrid, p=2, p2=2): """ # Checks that the lp normed is well defined - if p < 1: + if not (p == 'inf' or np.isinf(p)) and p < 1: raise ValueError(f"p must be equal or greater than 1.") if fdatagrid.ndim_image > 1: + if p2 == 'inf': + p2 = np.inf data_matrix = np.linalg.norm(fdatagrid.data_matrix, ord=p2, axis=-1, keepdims=True) else: @@ -317,7 +312,7 @@ def norm_lp(fdatagrid, p=2, p2=2): return res -def lp_distance(fdata1, fdata2, p=2, *, eval_points=None, check=True): +def lp_distance(fdata1, fdata2, p=2, p2=2, *, eval_points=None, _check=True): r"""Lp distance for FDataGrid objects. Calculates the distance between all possible pairs of one sample of @@ -330,6 +325,14 @@ def lp_distance(fdata1, fdata2, p=2, *, eval_points=None, check=True): The norm is specified as a parameter but defaults to the l2 norm. + Args: + fdatagrid (FDataGrid): FDataGrid object. + p (int, optional): p of the lp norm. Must be greater or equal + than 1. If p='inf' or p=np.inf it is used the L infinity metric. + Defaults to 2. + p2 (int, optional): p index of the vectorial norm applied in case of + multivariate objects. Defaults to 2. See :func:`norm_lp`. + Examples: Computes the distances between an object containing functional data corresponding to the functions y = 1 and y = x defined over the @@ -358,12 +361,12 @@ def lp_distance(fdata1, fdata2, p=2, *, eval_points=None, check=True): # Checks fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, - check=check) + _check=_check) + + return norm_lp(fdata1 - fdata2, p=p, p2=p2) - return norm_lp(fdata1 - fdata2, p=p) - -def fisher_rao_distance(fdata1, fdata2, *, eval_points=None, check=True): +def fisher_rao_distance(fdata1, fdata2, *, eval_points=None, _check=True): r"""Compute the Fisher-Rao distance between two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let @@ -401,7 +404,7 @@ def fisher_rao_distance(fdata1, fdata2, *, eval_points=None, check=True): """ fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, - check=check) + _check=_check) # Both should have the same sample points eval_points_normalized = _normalize_scale(fdata1.sample_points[0]) @@ -419,8 +422,8 @@ def fisher_rao_distance(fdata1, fdata2, *, eval_points=None, check=True): return lp_distance(fdata1_srsf, fdata2_srsf, p=2) -def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, - **kwargs): +def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, + _check=True, **kwargs): r"""Compute the amplitude distance between two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let @@ -470,7 +473,7 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, """ fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, - check=check) + _check=_check) # Both should have the same sample points eval_points_normalized = _normalize_scale(fdata1.sample_points[0]) @@ -509,8 +512,8 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, return distance - -def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, + +def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, _check=True, **kwargs): r"""Compute the amplitude distance btween two functional objects. @@ -552,7 +555,7 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, """ fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, eval_points=eval_points, - check=check) + _check=_check) # Rescale in (0,1) eval_points_normalized = _normalize_scale(fdata1.sample_points[0]) @@ -579,7 +582,7 @@ def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, check=True, return np.arccos(d) -def warping_distance(warping1, warping2, *, eval_points=None, check=True): +def warping_distance(warping1, warping2, *, eval_points=None, _check=True): r"""Compute the distance between warpings functions. Let :math:`\gamma_i` and :math:`\gamma_j` be two warpings, defined in @@ -616,7 +619,7 @@ def warping_distance(warping1, warping2, *, eval_points=None, check=True): """ warping1, warping2 = _cast_to_grid(warping1, warping2, - eval_points=eval_points, check=check) + eval_points=eval_points, _check=_check) # Normalization of warping to (0,1)x(0,1) warping1 = normalize_warping(warping1, (0, 1)) diff --git a/tests/test_metrics.py b/tests/test_metrics.py new file mode 100644 index 000000000..47d614deb --- /dev/null +++ b/tests/test_metrics.py @@ -0,0 +1,109 @@ +import unittest + +import scipy.stats.mstats + +import numpy as np +from skfda import FDataGrid, FDataBasis +from skfda.representation.basis import Monomial +from skfda.exploratory import stats +from skfda.datasets import make_multimodal_samples +from skfda.misc.metrics import lp_distance, norm_lp, vectorial_norm + + +class TestLpMetrics(unittest.TestCase): + + def setUp(self): + sample_points = [1, 2, 3, 4, 5] + self.fd = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], + sample_points=sample_points) + basis = Monomial(nbasis=3, domain_range=(1, 5)) + self.fd_basis = FDataBasis(basis, [[1, 1, 0], [0, 0, 1]]) + self.fd_curve = self.fd.concatenate(self.fd, as_coordinates=True) + self.fd_surface = make_multimodal_samples(n_samples=3, ndim_domain=2, + random_state=0) + + def test_vectorial_norm(self): + + vec = vectorial_norm(self.fd_curve, p=2) + np.testing.assert_array_almost_equal(vec.data_matrix, + np.sqrt(2)* self.fd.data_matrix) + + vec = vectorial_norm(self.fd_curve, p='inf') + np.testing.assert_array_almost_equal(vec.data_matrix, + self.fd.data_matrix) + + def test_vectorial_norm_surface(self): + + fd_surface_curve = self.fd_surface.concatenate(self.fd_surface, + as_coordinates=True) + vec = vectorial_norm(fd_surface_curve, p=2) + np.testing.assert_array_almost_equal( + vec.data_matrix, np.sqrt(2) * self.fd_surface.data_matrix) + + vec = vectorial_norm(fd_surface_curve, p='inf') + np.testing.assert_array_almost_equal(vec.data_matrix, + self.fd_surface.data_matrix) + + def test_norm_lp(self): + + np.testing.assert_allclose(norm_lp(self.fd, p=1), [16., 41.33333333]) + np.testing.assert_allclose(norm_lp(self.fd, p='inf'), [6, 25]) + + def test_norm_lp_curve(self): + + np.testing.assert_allclose(norm_lp(self.fd_curve, p=1, p2=1), + [32., 82.666667]) + np.testing.assert_allclose(norm_lp(self.fd_curve, p='inf', p2='inf'), + [6, 25]) + + def test_norm_lp_surface_inf(self): + np.testing.assert_allclose(norm_lp(self.fd_surface, p='inf').round(5), + [0.99994, 0.99793 , 0.99868]) + + def test_norm_lp_surface(self): + # Integration of surfaces not implemented, add test case after + # implementation + self.assertEqual(norm_lp(self.fd_surface), NotImplemented) + + def test_lp_error_dimensions(self): + # Case internal arrays + with np.testing.assert_raises(ValueError): + lp_distance(self.fd, self.fd_surface) + + with np.testing.assert_raises(ValueError): + lp_distance(self.fd, self.fd_curve) + + with np.testing.assert_raises(ValueError): + lp_distance(self.fd_surface, self.fd_curve) + + def test_lp_error_domain_ranges(self): + sample_points = [2, 3, 4, 5, 6] + fd2 = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], + sample_points=sample_points) + + with np.testing.assert_raises(ValueError): + lp_distance(self.fd, fd2) + + def test_lp_error_sample_points(self): + sample_points = [1, 2, 4, 4.3, 5] + fd2 = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], + sample_points=sample_points) + + with np.testing.assert_raises(ValueError): + lp_distance(self.fd, fd2) + + def test_lp_grid_basis(self): + + np.testing.assert_allclose(lp_distance(self.fd, self.fd_basis), 0) + np.testing.assert_allclose(lp_distance(self.fd_basis, self.fd), 0) + np.testing.assert_allclose( + lp_distance(self.fd_basis, + self.fd_basis, eval_points=[1, 2, 3, 4, 5]), 0) + np.testing.assert_allclose(lp_distance(self.fd_basis, self.fd_basis), + 0) + + + +if __name__ == '__main__': + print() + unittest.main() From 7a0bc34edafd1f0817fc46405e965dc98b664cdc Mon Sep 17 00:00:00 2001 From: pablomm Date: Thu, 8 Aug 2019 01:25:02 +0200 Subject: [PATCH 163/222] Documentation --- docs/modules/misc.rst | 10 +- docs/modules/ml.rst | 10 +- docs/modules/ml/classification.rst | 13 +- docs/modules/ml/clustering.rst | 38 +++-- docs/modules/ml/clustering/.gitignore | 1 - docs/modules/ml/clustering/base_kmeans.rst | 18 --- docs/modules/ml/regression.rst | 21 ++- .../plot_neighbors_functional_regression.py | 130 ++++++++++++++++++ skfda/_neighbors/base.py | 6 +- skfda/_neighbors/unsupervised.py | 2 +- skfda/ml/classification/__init__.py | 2 +- skfda/ml/clustering/__init__.py | 1 + tests/test_neighbors.py | 5 +- 13 files changed, 209 insertions(+), 48 deletions(-) delete mode 100644 docs/modules/ml/clustering/.gitignore delete mode 100644 docs/modules/ml/clustering/base_kmeans.rst create mode 100644 examples/plot_neighbors_functional_regression.py diff --git a/docs/modules/misc.rst b/docs/modules/misc.rst index 5cb2c8987..66654d9c6 100644 --- a/docs/modules/misc.rst +++ b/docs/modules/misc.rst @@ -1,10 +1,10 @@ -Math -==== +Miscellaneous +============= -Miscelaneus functions and objects. +Miscellaneous functions and objects. .. toctree:: :maxdepth: 4 :caption: Modules: - - misc/metrics \ No newline at end of file + + misc/metrics diff --git a/docs/modules/ml.rst b/docs/modules/ml.rst index a26026670..ec4eb494f 100644 --- a/docs/modules/ml.rst +++ b/docs/modules/ml.rst @@ -1,12 +1,14 @@ Machine Learning ================ - -Machine Learning module. - +This module contains classes compatible with the +`scikit-learn `_ estimators and utilities for +solving machine learning problems. It consists of three sub-modules: +:ref:`classification-module`, :ref:`clustering-module` and +:ref:`regression-module`. .. toctree:: - :maxdepth: 4 + :maxdepth: 3 :caption: Modules: ml/classification diff --git a/docs/modules/ml/classification.rst b/docs/modules/ml/classification.rst index 7118d1f61..fb4e15f3d 100644 --- a/docs/modules/ml/classification.rst +++ b/docs/modules/ml/classification.rst @@ -1,13 +1,21 @@ +.. _classification-module: + Classification ============== -Header Classification +Module with classes to perform classification of functional data. Nearest Neighbors ----------------- -Introduction to nearest neighbors +This module contains `nearest neighbors +`_ estimators to +perform classification. In the examples `K-nearest neighbors classification +<../../../auto_examples/plot_k_neighbors_classification.html>`_ and +`Radius neighbors classification +<../../../auto_examples/plot_radius_neighbors_classification.html>`_ +it is explained the basic usage of these estimators. .. autosummary:: :toctree: autosummary @@ -15,4 +23,3 @@ Introduction to nearest neighbors skfda.ml.classification.KNeighborsClassifier skfda.ml.classification.RadiusNeighborsClassifier skfda.ml.classification.NearestCentroids - skfda.ml.classification.NearestNeighbors diff --git a/docs/modules/ml/clustering.rst b/docs/modules/ml/clustering.rst index 67fde76f1..63979165d 100644 --- a/docs/modules/ml/clustering.rst +++ b/docs/modules/ml/clustering.rst @@ -1,14 +1,36 @@ +.. _clustering-module: + Clustering ========== -Functions to cluster functional data in a FDataGrid object. +Module with classes to perform clustering of functional data. + + +K means algorithms +------------------ + +The following classes implement both, the K-Means and the Fuzzy K-Means +algorithms respectively. In order to show the results in a visual way, +the module :mod:`skfda.exploratory.visualization.clustering_plots +` can be used. +See the `Clustering Example <../auto_examples/plot_clustering.html>`_ for a +detailed explanation. + +.. autosummary:: + :toctree: autosummary + + skfda.ml.clustering.KMeans + skfda.ml.clustering.FuzzyKMeans + + +Nearest Neighbors +----------------- -This module contains functions to group observations in such a way that those in -the same group (called a cluster) are more similar (in some sense) to each other -than to those in other groups (clusters). +The class :class:`NearestNeighbors ` +implements the nearest neighbors algorithm to perform unsupervised neighbor +searches. -.. toctree:: - :maxdepth: 4 - :caption: Modules: +.. autosummary:: + :toctree: autosummary - clustering/base_kmeans \ No newline at end of file + skfda.ml.clustering.NearestNeighbors diff --git a/docs/modules/ml/clustering/.gitignore b/docs/modules/ml/clustering/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/ml/clustering/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ diff --git a/docs/modules/ml/clustering/base_kmeans.rst b/docs/modules/ml/clustering/base_kmeans.rst deleted file mode 100644 index e9fb9dfd1..000000000 --- a/docs/modules/ml/clustering/base_kmeans.rst +++ /dev/null @@ -1,18 +0,0 @@ -KMeans algorithms -================= - -The following classes implement both, the K-Means and the Fuzzy K-Means algorithms -respectively. They both inherit from the :class:`BaseKMeans class -`. - -.. autosummary:: - :toctree: autosummary - - skfda.ml.clustering.base_kmeans.KMeans - skfda.ml.clustering.base_kmeans.FuzzyKMeans - -In order to show the results in a visual way, the module :mod:`clustering_plots -` can be used. - -See `Clustering Example <../auto_examples/plot_clustering.html>`_ for detailed -explanation. diff --git a/docs/modules/ml/regression.rst b/docs/modules/ml/regression.rst index 536a79e9e..0191022b8 100644 --- a/docs/modules/ml/regression.rst +++ b/docs/modules/ml/regression.rst @@ -1,13 +1,30 @@ +.. _regression-module: + Regression ========== -Header regression +Module with classes to perform regression of functional data. + +Linear regression +----------------- + +Todo: Add documentation of linear regression models. + +.. autosummary:: + :toctree: autosummary + skfda.ml.regression.LinearScalarRegression Nearest Neighbors ----------------- -Introduction to nearest neighbors regression +This module contains `nearest neighbors +`_ estimators to +perform regression. In the examples `Neighbors Scalar Regression +<../../../auto_examples/plot_neighbors_scalar_regression.html>`_ and +`Neighbors Functional Regression +<../../../auto_examples/plot_neighbors_functional_regression.html>`_ +it is explained the basic usage of these estimators. .. autosummary:: :toctree: autosummary diff --git a/examples/plot_neighbors_functional_regression.py b/examples/plot_neighbors_functional_regression.py new file mode 100644 index 000000000..d91ed7a74 --- /dev/null +++ b/examples/plot_neighbors_functional_regression.py @@ -0,0 +1,130 @@ +""" +Neighbors Functional Regression +=============================== + +Shows the usage of the nearest neighbors regressor with functional response. +""" + +# Author: Pablo Marcos Manchón +# License: MIT + +# sphinx_gallery_thumbnail_number = 4 + +import skfda +import matplotlib.pyplot as plt +import numpy as np +from sklearn.model_selection import train_test_split, GridSearchCV, KFold +from skfda.ml.regression import KNeighborsFunctionalRegressor +from skfda.representation.basis import Fourier +from skfda.preprocessing.registration import elastic_mean + + +################################################################################ +# +# In this example we are going to show the usage of the nearest neighbors +# regressors with functional response. There is available a K-nn version, +# :class:`KNeighborsFunctionalRegressor +# `, and other one based in +# the radius, :class:`RadiusNeighborsFunctionalRegressor +# `. +# +# +# As in the scalar response example, we will fetch the caniadian weather +# dataset, which contains the daily temperature and +# precipitation at 35 different locations in Canada averaged over 1960 to 1994. +# The following figure shows the different temperature and precipitation +# curves. +# + +data = skfda.datasets.fetch_weather() +fd = data['data'] + + +# Split dataset, temperatures and curves of precipitation +X, y = fd.coordinates + +plt.figure() +X.plot() + +plt.figure() +y.plot() + +################################################################################ +# +# We will try to predict the precipitation curves. First of all we are going to +# make a smoothing of the precipitation curves using a basis representation, +# employing for it a fourier basis with 5 elements. +# + + +y = y.to_basis(Fourier(nbasis=5)) + +plt.figure() +y.plot() + + +################################################################################ +# +# We will split the dataset in two partitions, for training and test, +# using the sklearn function :func:`sklearn.model_selection.train_test_split`. +# + +X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.1, + random_state=28) + +################################################################################ +# +# We will try make a prediction using 5 neighbors and the :math:`\mathbb{L}^2` +# distance. In this case, to calculate +# the response we will use a mean of the response, weighted by their distance +# to the test sample. +# + + +knn = KNeighborsFunctionalRegressor(n_neighbors=5, weights='distance') +knn.fit(X_train, y_train) + +################################################################################ +# +# We can predict values for the test partition using :meth:`predict`. The +# following figure shows the real precipitation curves, in dashed line, and +# the predicted ones. +# + +y_pred = knn.predict(X_test) + +# Plot prediction +plt.figure() +fig, ax = y_pred.plot() +ax[0].set_prop_cycle(None) # Reset colors +y_test.plot(linestyle='--') + + +################################################################################ +# +# We can quantify how much variability it is explained by the model +# using the :meth:`score` method, which computes the value +# +# .. math:: +# 1 - \frac{\sum_{i=1}^{n}\int (y_i(t) - \hat{y}_i(t))^2dt} +# {\sum_{i=1}^{n} \int (y_i(t)- \frac{1}{n}\sum_{i=1}^{n}y_i(t))^2dt} +# +# where :math:`y_i` are the real responses and :math:`\hat{y}_i` the +# predicted ones. + +score = knn.score(X_test, y_test) +print(score) + +################################################################################ +# +# More detailed information about the canadian weather dataset can be obtained +# in the following references. +# +# * Ramsay, James O., and Silverman, Bernard W. (2006). Functional Data +# Analysis, 2nd ed. , Springer, New York. +# +# * Ramsay, James O., and Silverman, Bernard W. (2002). Applied Functional +# Data Analysis, Springer, New York\n' +# + +plt.show() diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index ab607f6f7..c0bc42231 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -208,7 +208,7 @@ def kneighbors(self, X=None, n_neighbors=None, return_distance=True): We will fit a Nearest Neighbors estimator - >>> from skfda.ml.classification import NearestNeighbors + >>> from skfda.ml.clustering import NearestNeighbors >>> neigh = NearestNeighbors() >>> neigh.fit(fd) NearestNeighbors(algorithm='auto', leaf_size=30,...) @@ -265,7 +265,7 @@ def kneighbors_graph(self, X=None, n_neighbors=None, mode='connectivity'): We will fit a Nearest Neighbors estimator. - >>> from skfda.ml.classification import NearestNeighbors + >>> from skfda.ml.clustering import NearestNeighbors >>> neigh = NearestNeighbors() >>> neigh.fit(fd) NearestNeighbors(algorithm='auto', leaf_size=30,...) @@ -334,7 +334,7 @@ def radius_neighbors(self, X=None, radius=None, return_distance=True): We will fit a Nearest Neighbors estimator. - >>> from skfda.ml.classification import NearestNeighbors + >>> from skfda.ml.clustering import NearestNeighbors >>> neigh = NearestNeighbors(radius=.3) >>> neigh.fit(fd) NearestNeighbors(algorithm='auto', leaf_size=30,...) diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index f1995de44..dcd1a3aca 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -57,7 +57,7 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, We will fit a Nearest Neighbors estimator - >>> from skfda.ml.classification import NearestNeighbors + >>> from skfda.ml.clustering import NearestNeighbors >>> neigh = NearestNeighbors(radius=.3) >>> neigh.fit(fd) NearestNeighbors(algorithm='auto', leaf_size=30,...) diff --git a/skfda/ml/classification/__init__.py b/skfda/ml/classification/__init__.py index 923ebe7c1..6f69cb3a8 100644 --- a/skfda/ml/classification/__init__.py +++ b/skfda/ml/classification/__init__.py @@ -1,4 +1,4 @@ from ..._neighbors import (KNeighborsClassifier, RadiusNeighborsClassifier, - NearestNeighbors, NearestCentroids) + NearestCentroids) diff --git a/skfda/ml/clustering/__init__.py b/skfda/ml/clustering/__init__.py index c29ec462d..96b818792 100644 --- a/skfda/ml/clustering/__init__.py +++ b/skfda/ml/clustering/__init__.py @@ -2,3 +2,4 @@ from . import base_kmeans from .base_kmeans import KMeans, FuzzyKMeans +from ..._neighbors import NearestNeighbors diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 5afaa3edd..8906d78e3 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -7,14 +7,15 @@ from skfda.ml.classification import (KNeighborsClassifier, RadiusNeighborsClassifier, - NearestCentroids, - NearestNeighbors) + NearestCentroids) from skfda.ml.regression import (KNeighborsScalarRegressor, RadiusNeighborsScalarRegressor, KNeighborsFunctionalRegressor, RadiusNeighborsFunctionalRegressor) +from skfda.ml.clustering import NearestNeighbors + from skfda.misc.metrics import lp_distance, pairwise_distance from skfda.representation.basis import Fourier From 51c54c5f24b81c43214164398cb68c225ee23b14 Mon Sep 17 00:00:00 2001 From: pablomm Date: Thu, 8 Aug 2019 13:01:34 +0200 Subject: [PATCH 164/222] Change callable in default parameter to str --- skfda/_neighbors/base.py | 29 +++++++++++++++++++++-------- skfda/_neighbors/classification.py | 22 +++++++++++++--------- skfda/_neighbors/regression.py | 12 ++++++------ skfda/_neighbors/unsupervised.py | 2 +- 4 files changed, 41 insertions(+), 24 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index c0bc42231..4f151f06d 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -10,6 +10,7 @@ from .. import FDataGrid from ..misc.metrics import lp_distance +from ..exploratory.stats import mean as l2_mean def _to_multivariate(fdatagrid): @@ -99,7 +100,7 @@ class NeighborsBase(ABC, BaseEstimator): @abstractmethod def __init__(self, n_neighbors=None, radius=None, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, + leaf_size=30, metric='lp_distance', metric_params=None, n_jobs=None, sklearn_metric=False): self.n_neighbors = n_neighbors @@ -161,8 +162,11 @@ def fit(self, X, y=None): if not self.sklearn_metric: # Constructs sklearn metric to manage vector - sk_metric = _to_sklearn_metric( - self.metric, self._sample_points) + if self.metric == 'lp_distance': + metric = lp_distance + else: + metric = self.metric + sk_metric = _to_sklearn_metric(metric, self._sample_points) else: sk_metric = self.metric @@ -505,14 +509,23 @@ def fit(self, X, y): if not self.sklearn_metric: # Constructs sklearn metric to manage vector instead of grids - sk_metric = _to_sklearn_metric( - self.metric, self._sample_points) + if self.metric == 'lp_distance': + metric = lp_distance + else: + metric = self.metric + + sk_metric = _to_sklearn_metric(metric, self._sample_points) else: sk_metric = self.metric self.estimator_ = self._init_estimator(sk_metric) self.estimator_.fit(self._transform_to_multivariate(X)) + if self.regressor == 'mean': + self._regressor = l2_mean + else: + self._regressor = self.regressor + # Choose proper local regressor if self.weights == 'uniform': self.local_regressor = self._uniform_local_regression @@ -528,7 +541,7 @@ def fit(self, X, y): def _uniform_local_regression(self, neighbors, distance=None): """Perform local regression with uniform weights""" - return self.regressor(neighbors) + return self._regressor(neighbors) def _distance_local_regression(self, neighbors, distance): """Perform local regression using distances as weights""" @@ -541,14 +554,14 @@ def _distance_local_regression(self, neighbors, distance): weights = 1. / distance weights /= np.sum(weights) - return self.regressor(neighbors, weights) + return self._regressor(neighbors, weights) def _weighted_local_regression(self, neighbors, distance): """Perform local regression using custom weights""" weights = self.weights(distance) - return self.regressor(neighbors, weights) + return self._regressor(neighbors, weights) def predict(self, X): """Predict functional responses. diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 386491131..6da07dfd1 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -12,7 +12,7 @@ _RadiusNeighborsClassifier) from ..misc.metrics import lp_distance, pairwise_distance -from ..exploratory.stats import mean +from ..exploratory.stats import mean as l2_mean class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, @@ -118,7 +118,7 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, """ def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, + leaf_size=30, metric='lp_distance', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -266,7 +266,7 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, """ def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, + leaf_size=30, metric='lp_distance', metric_params=None, outlier_label=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -309,12 +309,12 @@ class NearestCentroids(BaseEstimator, ClassifierMixin): The metric to use when calculating distance between test samples and centroids. See the documentation of the metrics module for a list of available metrics. Defaults used L2 distance. - mean: callable, (default :func:`mean `) + centroid: callable, (default :func:`mean `) The centroids for the samples corresponding to each class is the point from which the sum of the distances (according to the metric) of all samples that belong to that particular class are minimized. By default it is used the usual mean, which minimizes the sum of L2 - distance. This parameter allows change the centroid constructor. + distances. This parameter allows change the centroid constructor. The function must accept a :class:`FData` with the samples of one class and return a :class:`FData` object with only one sample representing the centroid. @@ -355,7 +355,7 @@ class and return a :class:`FData` object with only one sample """ - def __init__(self, metric=lp_distance, mean=mean): + def __init__(self, metric='lp_distance', mean='mean'): """Initialize the classifier.""" self.metric = metric self.mean = mean @@ -373,8 +373,12 @@ def fit(self, X, y): """ if self.metric == 'precomputed': raise ValueError("Precomputed is not supported.") + elif self.metric == 'lp_distance': + self._pairwise_distance = pairwise_distance(lp_distance) + else: + self._pairwise_distance = pairwise_distance(self.metric) - self._pairwise_distance = pairwise_distance(self.metric) + mean = l2_mean if self.mean == 'mean' else self.mean check_classification_targets(y) @@ -386,11 +390,11 @@ def fit(self, X, y): raise ValueError(f'The number of classes has to be greater than' f' one; got {n_classes} class') - self.centroids_ = self.mean(X[y_ind == 0]) + self.centroids_ = mean(X[y_ind == 0]) for cur_class in range(1, n_classes): center_mask = y_ind == cur_class - centroid = self.mean(X[center_mask]) + centroid = mean(X[center_mask]) self.centroids_ = self.centroids_.concatenate(centroid) return self diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index 9cb59d886..40e6cf600 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -116,7 +116,7 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, """ def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, + leaf_size=30, metric='lp_distance', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -242,7 +242,7 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, """ def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, + leaf_size=30, metric='lp_distance', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -371,8 +371,8 @@ class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, """ - def __init__(self, n_neighbors=5, weights='uniform', regressor=mean, - algorithm='auto', leaf_size=30, metric=lp_distance, + def __init__(self, n_neighbors=5, weights='uniform', regressor='mean', + algorithm='auto', leaf_size=30, metric='lp_distance', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -492,8 +492,8 @@ class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, """ - def __init__(self, radius=1., weights='uniform', regressor=mean, - algorithm='auto', leaf_size=30, metric=lp_distance, + def __init__(self, radius=1., weights='uniform', regressor='mean', + algorithm='auto', leaf_size=30, metric='lp_distance', metric_params=None, outlier_response=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index dcd1a3aca..229efe066 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -101,7 +101,7 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, """ def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', - leaf_size=30, metric=lp_distance, metric_params=None, + leaf_size=30, metric='lp_distance', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the nearest neighbors searcher.""" From c56f38f1d7d8de9c70f405bca7683bee0ee6bbf3 Mon Sep 17 00:00:00 2001 From: pablomm Date: Thu, 8 Aug 2019 13:47:56 +0200 Subject: [PATCH 165/222] Unused imports and pep8 corrections --- examples/plot_k_neighbors_classification.py | 8 +++++--- examples/plot_neighbors_functional_regression.py | 3 +-- examples/plot_neighbors_scalar_regression.py | 2 +- examples/plot_radius_neighbors_classification.py | 2 +- skfda/_neighbors/__init__.py | 4 ++-- skfda/_neighbors/base.py | 4 ++-- skfda/_neighbors/classification.py | 3 ++- skfda/_neighbors/regression.py | 3 --- skfda/_neighbors/unsupervised.py | 2 -- 9 files changed, 14 insertions(+), 17 deletions(-) diff --git a/examples/plot_k_neighbors_classification.py b/examples/plot_k_neighbors_classification.py index 4b1374da2..494cff4e2 100644 --- a/examples/plot_k_neighbors_classification.py +++ b/examples/plot_k_neighbors_classification.py @@ -107,7 +107,7 @@ # using :func:`predict_proba`, which will return an array with the # probabilities of the classes, in lexicographic order, for each test sample. -probs = knn.predict_proba(X_test[:5]) # Predict first 5 samples +probs = knn.predict_proba(X_test[:5]) # Predict first 5 samples print(probs) @@ -200,8 +200,10 @@ # print("Mean score time (milliseconds)") -print("L2 distance:", 1000*np.mean(gscv.cv_results_['mean_score_time']), "(ms)") -print("Euclidean distance:", 1000*np.mean(gscv2.cv_results_['mean_score_time']), "(ms)") +print("L2 distance:", 1000 * + np.mean(gscv.cv_results_['mean_score_time']), "(ms)") +print("Euclidean distance:", 1000 * + np.mean(gscv2.cv_results_['mean_score_time']), "(ms)") ################################################################################ # diff --git a/examples/plot_neighbors_functional_regression.py b/examples/plot_neighbors_functional_regression.py index d91ed7a74..079981871 100644 --- a/examples/plot_neighbors_functional_regression.py +++ b/examples/plot_neighbors_functional_regression.py @@ -13,10 +13,9 @@ import skfda import matplotlib.pyplot as plt import numpy as np -from sklearn.model_selection import train_test_split, GridSearchCV, KFold +from sklearn.model_selection import train_test_split from skfda.ml.regression import KNeighborsFunctionalRegressor from skfda.representation.basis import Fourier -from skfda.preprocessing.registration import elastic_mean ################################################################################ diff --git a/examples/plot_neighbors_scalar_regression.py b/examples/plot_neighbors_scalar_regression.py index f80eb30d1..8558c79e5 100644 --- a/examples/plot_neighbors_scalar_regression.py +++ b/examples/plot_neighbors_scalar_regression.py @@ -56,7 +56,7 @@ # # Sum directly from the data matrix -prec = y_func.data_matrix.sum(axis=1)[:,0] +prec = y_func.data_matrix.sum(axis=1)[:, 0] log_prec = np.log(prec) print(log_prec) diff --git a/examples/plot_radius_neighbors_classification.py b/examples/plot_radius_neighbors_classification.py index 6997c17e2..a4deadcb1 100644 --- a/examples/plot_radius_neighbors_classification.py +++ b/examples/plot_radius_neighbors_classification.py @@ -14,7 +14,7 @@ import skfda import matplotlib.pyplot as plt import numpy as np -from sklearn.model_selection import train_test_split, GridSearchCV, KFold +from sklearn.model_selection import train_test_split from skfda.ml.classification import RadiusNeighborsClassifier from skfda.misc.metrics import pairwise_distance, lp_distance diff --git a/skfda/_neighbors/__init__.py b/skfda/_neighbors/__init__.py index ab4ebdf52..9134bfb08 100644 --- a/skfda/_neighbors/__init__.py +++ b/skfda/_neighbors/__init__.py @@ -1,5 +1,5 @@ """Private module with the implementation of the neighbors estimators -Includes the following classes estimators: +Includes the following classes: - NearestNeighbors - KNeighborsClassifier - RadiusNeighborsClassifier @@ -7,7 +7,7 @@ - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor - KNeighborsFunctionalRegressor - - RadiusNeighborsFunctionalRegressor' + - RadiusNeighborsFunctionalRegressor """ from .unsupervised import NearestNeighbors diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 4f151f06d..c83d938e0 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -1,6 +1,6 @@ -from abc import ABC, abstractmethod, abstractproperty +from abc import ABC, abstractmethod import numpy as np from sklearn.base import BaseEstimator @@ -508,7 +508,7 @@ def fit(self, X, y): self._shape = X.data_matrix.shape[1:] if not self.sklearn_metric: - # Constructs sklearn metric to manage vector instead of grids + if self.metric == 'lp_distance': metric = lp_distance else: diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 6da07dfd1..3e41b1e1c 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -309,7 +309,8 @@ class NearestCentroids(BaseEstimator, ClassifierMixin): The metric to use when calculating distance between test samples and centroids. See the documentation of the metrics module for a list of available metrics. Defaults used L2 distance. - centroid: callable, (default :func:`mean `) + centroid: callable, (default + :func:`mean `) The centroids for the samples corresponding to each class is the point from which the sum of the distances (according to the metric) of all samples that belong to that particular class are minimized. diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index 40e6cf600..85648725b 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -11,9 +11,6 @@ NeighborsFunctionalRegressorMixin, NearestNeighborsMixinInit) -from ..exploratory.stats import mean -from ..misc.metrics import lp_distance - class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, KNeighborsMixin, RegressorMixin, diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index 229efe066..4ecce40cb 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -2,8 +2,6 @@ from .base import (NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, KNeighborsMixin, RadiusNeighborsMixin, _to_sklearn_metric) -from ..misc.metrics import lp_distance - class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, KNeighborsMixin, RadiusNeighborsMixin): From 96c38c0bd54025c2a4df5ab6d8248ee6c4f6614c Mon Sep 17 00:00:00 2001 From: pablomm Date: Thu, 8 Aug 2019 14:27:30 +0200 Subject: [PATCH 166/222] Increase coverage --- tests/test_neighbors.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 8906d78e3..e3bda69a7 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -18,6 +18,7 @@ from skfda.misc.metrics import lp_distance, pairwise_distance from skfda.representation.basis import Fourier +from skfda.exploratory.stats import mean as l2_mean class TestNeighbors(unittest.TestCase): @@ -51,7 +52,8 @@ def test_predict_classifier(self): for neigh in (KNeighborsClassifier(), RadiusNeighborsClassifier(radius=.1), - NearestCentroids()): + NearestCentroids(), + NearestCentroids(metric=lp_distance, mean=l2_mean)): neigh.fit(self.X, self.y) pred = neigh.predict(self.X) @@ -61,7 +63,7 @@ def test_predict_classifier(self): def test_predict_proba_classifier(self): """Tests predict proba for k neighbors classifier""" - neigh = KNeighborsClassifier() + neigh = KNeighborsClassifier(metric=lp_distance) neigh.fit(self.X, self.y) probs = neigh.predict_proba(self.X) @@ -176,7 +178,9 @@ def test_knn_functional_response_precomputed(self): self.X[:4].data_matrix) def test_radius_functional_response(self): - knnr = RadiusNeighborsFunctionalRegressor(weights='distance') + knnr = RadiusNeighborsFunctionalRegressor(metric=lp_distance, + weights='distance', + regressor=l2_mean) knnr.fit(self.X, self.X) From b14add0254bbe3b9c1944b1c2b5065474905e4e1 Mon Sep 17 00:00:00 2001 From: pablomm Date: Thu, 8 Aug 2019 17:16:44 +0200 Subject: [PATCH 167/222] setup long description --- README.rst | 66 ++++++++++++++++++++++++++++++------------------------ setup.py | 58 +++++++++++++++++++++++++++++++++++------------ 2 files changed, 81 insertions(+), 43 deletions(-) diff --git a/README.rst b/README.rst index c98132a50..07a5a7017 100644 --- a/README.rst +++ b/README.rst @@ -6,52 +6,55 @@ scikit-fda: Functional Data Analysis in Python |python|_ |build-status| |docs| |Codecov|_ |PyPIBadge|_ |license|_ -Functional Data Analysis, or FDA, is the field of Statistics that analyses data that -depend on a continuous parameter. +Functional Data Analysis, or FDA, is the field of Statistics that analyses +data that depend on a continuous parameter. This package offers classes, methods and functions to give support to FDA in Python. Includes a wide range of utils to work with functional data, and its -representation, exploratory analysis, or preprocessing, among other tasks such as inference, classification, -regression or clustering of functional data. See documentation for further information on the features -included in the package. +representation, exploratory analysis, or preprocessing, among other tasks +such as inference, classification, regression or clustering of functional data. +See documentation for further information on the features included in the +package. Documentation ============= -The documentation is available at -`fda.readthedocs.io/en/stable/ `_, which -includes detailed information of the different modules, classes and methods of the package, along with several examples_ -showing different funcionalities. +The documentation is available at +`fda.readthedocs.io/en/stable/ `_, which +includes detailed information of the different modules, classes and methods of +the package, along with several examples showing different funcionalities. -The documentation of the latest version, corresponding with the develop version of the package, can be found at +The documentation of the latest version, corresponding with the develop +version of the package, can be found at `fda.readthedocs.io/en/latest/ `_. Installation ============ -Currently, *scikit-fda* is available in Python 3.6 and 3.7, regardless of the platform. +Currently, *scikit-fda* is available in Python 3.6 and 3.7, regardless of the +platform. The stable version can be installed via PyPI_: -.. code:: +.. code:: pip install scikit-fda - + Installation from source ------------------------ - -It is possible to install the latest version of the package, available in the develop branch, -by cloning this repository and doing a manual installation. - -.. code:: + +It is possible to install the latest version of the package, available in the +develop branch, by cloning this repository and doing a manual installation. + +.. code:: git clone https://github.com/GAA-UAM/scikit-fda.git cd scikit-fda/ pip install -r requirements.txt # Install dependencies python setup.py install -Make sure that your default Python version is currently supported, or change the python and pip -commands by specifying a version, such as ``python3.6``: +Make sure that your default Python version is currently supported, or change +the python and pip commands by specifying a version, such as ``python3.6``: -.. code:: +.. code:: git clone https://github.com/GAA-UAM/scikit-fda.git cd scikit-fda/ @@ -62,8 +65,8 @@ Requirements ------------ *scikit-fda* depends on the following packages: -* `setuptools `_ - Python Packaging -* `cython `_ - Python to C compiler +* `setuptools `_ - Python Packaging +* `cython `_ - Python to C compiler * `numpy `_ - The fundamental package for scientific computing with Python * `pandas `_ - Powerful Python data analysis toolkit * `scipy `_ - Scientific computation in Python @@ -73,24 +76,29 @@ Requirements * `rdata `_ - Reader of R datasets in .rda format in Python * `scikit-datasets `_ - Scikit-learn compatible datasets -The dependencies are automatically installed during the installation. +The dependencies are automatically installed. Contributions ============= -All contributions are welcome. You can help this project grow in multiple ways, from creating an issue, reporting an improvement or a bug, to doing a repository fork and creating a pull request to the development branch. +All contributions are welcome. You can help this project grow in multiple ways, +from creating an issue, reporting an improvement or a bug, to doing a +repository fork and creating a pull request to the development branch. -The people involved at some point in the development of the package can be found in the `contributors file `_. +The people involved at some point in the development of the package can be +found in the `contributors +file `_. Citation ======== If you find this project useful, please cite: -.. todo:: Include citation to scikit-fda paper. +.. todo:: Include citation to scikit-fda paper. License ======= -The package is licensed under the BSD 3-Clause License. A copy of the license_ can be found along with the code. +The package is licensed under the BSD 3-Clause License. A copy of the +license_ can be found along with the code. .. _examples: https://fda.readthedocs.io/en/latest/auto_examples/index.html .. _PyPI: https://pypi.org/project/scikit-fda/ @@ -107,7 +115,7 @@ The package is licensed under the BSD 3-Clause License. A copy of the license_ c :alt: Documentation Status :scale: 100% :target: http://fda.readthedocs.io/en/latest/?badge=latest - + .. |Codecov| image:: https://codecov.io/gh/GAA-UAM/scikit-fda/branch/develop/graph/badge.svg .. _Codecov: https://codecov.io/github/GAA-UAM/scikit-fda?branch=develop diff --git a/setup.py b/setup.py index 197893b37..904718482 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,25 @@ +# encoding: utf-8 + +""" +Functional Data Analysis Python package. + +Functional Data Analysis, or FDA, is the field of Statistics that analyses +data that depend on a continuous parameter. + +This package offers classes, methods and functions to give support to FDA +in Python. Includes a wide range of utils to work with functional data, and its +representation, exploratory analysis, or preprocessing, among other tasks +such as inference, classification, regression or clustering of functional data. +See documentation or visit the +`github page `_ of the project for +further information on the features included in the package. + +The documentation is available at +`fda.readthedocs.io/en/stable/ `_, which +includes detailed information of the different modules, classes and methods of +the package, along with several examples showing different funcionalities. +""" + import os import sys @@ -11,6 +33,8 @@ needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] +DOCLINES = (__doc__ or '').split("\n") + with open(os.path.join(os.path.dirname(__file__), 'VERSION'), 'r') as version_file: version = version_file.read().strip() @@ -32,8 +56,8 @@ setup(name='scikit-fda', version=version, - description='Functional Data Analysis Python package', - long_description="", # TODO + description=DOCLINES[1], + long_description="\n".join(DOCLINES[3:]), url='https://fda.readthedocs.io', maintainer='Carlos Ramos Carreño', maintainer_email='vnmabus@gmail.com', @@ -44,19 +68,25 @@ packages=find_packages(), python_requires='>=3.6, <4', classifiers=[ - 'Development Status :: 4 - Beta', - 'Intended Audience :: Developers', - 'Intended Audience :: Science/Research', - 'License :: OSI Approved :: BSD License', - 'Natural Language :: English', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.6', - 'Topic :: Scientific/Engineering :: Mathematics', - 'Topic :: Software Development :: Libraries :: Python Modules', + 'Development Status :: 4 - Beta', + 'Intended Audience :: Developers', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: BSD License', + 'Natural Language :: English', + 'Operating System :: OS Independent', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Topic :: Scientific/Engineering :: Mathematics', + 'Topic :: Software Development :: Libraries :: Python Modules', ], - install_requires=['numpy', 'scikit-learn', 'matplotlib', - 'scikit-datasets[cran]>=0.1.24', 'rdata', 'mpldatacursor'], + install_requires=['numpy', + 'scikit-learn', + 'matplotlib', + 'scikit-datasets[cran]>=0.1.24', + 'rdata', + 'mpldatacursor'], setup_requires=pytest_runner, - tests_require=['pytest', 'numpy>=1.14'], + tests_require=['pytest', + 'numpy>=1.14'], test_suite='tests', zip_safe=False) From bf4f2c26e45edee1307fe328591a85e8d6af85b4 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Sat, 10 Aug 2019 00:13:34 +0200 Subject: [PATCH 168/222] Corrected errors in directional outlyingness --- conftest.py | 6 +- skfda/exploratory/outliers/__init__.py | 1 + .../outliers/_directional_outlyingness.py | 205 ++++++++++++++++ .../visualization/magnitude_shape_plot.py | 228 +----------------- tests/test_magnitude_shape.py | 110 +++------ tests/test_outliers.py | 44 ++++ 6 files changed, 305 insertions(+), 289 deletions(-) create mode 100644 skfda/exploratory/outliers/_directional_outlyingness.py create mode 100644 tests/test_outliers.py diff --git a/conftest.py b/conftest.py index a60ade29e..889066c3a 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,6 @@ -#https://github.com/scikit-learn/scikit-learn/issues/8959 +import pytest + +# https://github.com/scikit-learn/scikit-learn/issues/8959 import numpy as np try: np.set_printoptions(sign=' ') @@ -6,3 +8,5 @@ pass collect_ignore = ['setup.py'] + +pytest.register_assert_rewrite("skfda") diff --git a/skfda/exploratory/outliers/__init__.py b/skfda/exploratory/outliers/__init__.py index e69de29bb..e66fde6d1 100644 --- a/skfda/exploratory/outliers/__init__.py +++ b/skfda/exploratory/outliers/__init__.py @@ -0,0 +1 @@ +from ._directional_outlyingness import directional_outlyingness_stats diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py new file mode 100644 index 000000000..135f09cb1 --- /dev/null +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -0,0 +1,205 @@ +import typing + +from numpy import linalg as la +import scipy.integrate + +import numpy as np + +from ... import FDataGrid +from ..depth import fraiman_muniz_depth + + +class DirectionalOutlyingnessStats(typing.NamedTuple): + directional_outlyingness: np.ndarray + functional_directional_outlyingness: np.ndarray + mean_directional_outlyingness: np.ndarray + variation_directional_outlyingness: np.ndarray + + +def directional_outlyingness_stats( + fdatagrid: FDataGrid, + depth_method=fraiman_muniz_depth, + pointwise_weights=None) -> DirectionalOutlyingnessStats: + r"""Computes the directional outlyingness of the functional data. + + Furthermore, it calculates functional, mean and the variational + directional outlyingness of the samples in the data set, which are also + returned. + + The functional directional outlyingness can be seen as the overall + outlyingness, analog to other functional outlyingness measures. + + The mean directional outlyingness, describes the relative + position (including both distance and direction) of the samples on average + to the center curve; its norm can be regarded as the magnitude + outlyingness. + + The variation of the directional outlyingness, measures + the change of the directional outlyingness in terms of both norm and + direction across the whole design interval and can be regarded as the + shape outlyingness. + + Firstly, the directional outlyingness is calculated as follows: + + .. math:: + \mathbf{O}\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right) = + \left\{\frac{1}{d\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right)} - 1 + \right\} \cdot \mathbf{v}(t) + + where :math:`\mathbf{X}` is a stochastic process with probability + distribution :math:`F`, :math:`d` a depth function and :math:`\mathbf{v}(t) + = \left\{ \mathbf{X}(t) - \mathbf{Z}(t)\right\} / \lVert \mathbf{X}(t) - + \mathbf{Z}(t) \rVert` is the spatial sign of :math:`\left\{\mathbf{X}(t) - + \mathbf{Z}(t)\right\}`, :math:`\mathbf{Z}(t)` denotes the median and + :math:`\lVert \cdot \rVert` denotes the :math:`L_2` norm. + + From the above formula, we define the mean directional outlyingness as: + + .. math:: + \mathbf{MO}\left(\mathbf{X} , F_{\mathbf{X}}\right) = \int_I + \mathbf{O}\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right) \cdot w(t) dt; + + and the variation of the directional outlyingness as: + + .. math:: + VO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \int_I \lVert\mathbf{O} + \left(\mathbf{X}(t), F_{\mathbf{X}(t)}\right)-\mathbf{MO}\left( + \mathbf{X} , F_{\mathbf{X}}\right) \rVert^2 \cdot w(t) dt + + where :math:`w(t)` a weight function defined on the domain of + :math:`\mathbf{X}`, :math:`I`. + + Then, the total functional outlyingness can be computed using these values: + + .. math:: + FO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \lVert \mathbf{MO}\left( + \mathbf{X} , F_{\mathbf{X}}\right)\rVert^2 + VO\left(\mathbf{X} , + F_{\mathbf{X}}\right) . + + Args: + fdatagrid (FDataGrid): Object containing the samples to be ordered + according to the directional outlyingness. + depth_method (:ref:`depth measure `, optional): Method + used to order the data. Defaults to :func:`modified band depth + `. + pointwise_weights (array_like, optional): an array containing the + weights of each point of discretisation where values have been + recorded. Defaults to the same weight for each of the points: + 1/len(interval). + + Returns: + DirectionalOutlyingnessStats object. + + Example: + + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> sample_points = [0, 2, 4, 6, 8, 10] + >>> fd = FDataGrid(data_matrix, sample_points) + >>> stats = directional_outlyingness_stats(fd) + >>> stats.directional_outlyingness + array([[[ 1. ], + [ 1. ], + [ 1. ], + [ 1. ], + [ 1. ], + [ 1. ]], + [[ 0.33333333], + [ 0.33333333], + [ 0.33333333], + [ 0.33333333], + [ 0.33333333], + [ 0.33333333]], + [[-0.33333333], + [-0.33333333], + [ 0. ], + [ 0. ], + [ 0. ], + [ 0. ]], + [[ 0. ], + [ 0. ], + [ 0. ], + [-0.33333333], + [-0.33333333], + [-0.33333333]]]) + + >>> stats.functional_directional_outlyingness + array([ 3.93209877, 3.27366255, 3.23765432, 3.25823045]) + + >>> stats.mean_directional_outlyingness + array([[ 1.66666667], + [ 0.55555556], + [-0.16666667], + [-0.27777778]]) + + >>> stats.variation_directional_outlyingness + array([ 0.74074074, 0.08230453, 0.0462963 , 0.06687243]) + + """ + if fdatagrid.ndim_domain > 1: + raise NotImplementedError("Only support 1 dimension on the domain.") + + if (pointwise_weights is not None and + (len(pointwise_weights) != len(fdatagrid.sample_points[0]) or + pointwise_weights.sum() != 1)): + raise ValueError( + "There must be a weight in pointwise_weights for each recorded " + "time point and altogether must sum 1.") + + if pointwise_weights is None: + pointwise_weights = np.ones( + len(fdatagrid.sample_points[0])) / len(fdatagrid.sample_points[0]) + + _, depth_pointwise = depth_method(fdatagrid, pointwise=True) + assert depth_pointwise.shape == fdatagrid.data_matrix.shape[:-1] + + # Obtaining the pointwise median sample Z, to calculate + # v(t) = {X(t) − Z(t)}/|| X(t) − Z(t) || + median_index = np.argmax(depth_pointwise, axis=0) + pointwise_median = fdatagrid.data_matrix[ + median_index, range(fdatagrid.data_matrix.shape[1])] + assert pointwise_median.shape == fdatagrid.shape[1:] + v = fdatagrid.data_matrix - pointwise_median + assert v.shape == fdatagrid.data_matrix.shape + v_norm = la.norm(v, axis=-1, keepdims=True) + + # To avoid ZeroDivisionError, the zeros are substituted by ones (the + # reference implementation also does this). + v_norm[np.where(v_norm == 0)] = 1 + + v_unitary = v / v_norm + + # Calculation directinal outlyingness + dir_outlyingness = (1 / depth_pointwise[..., np.newaxis] - 1) * v_unitary + assert dir_outlyingness.shape == fdatagrid.data_matrix.shape + + # Calculation mean directional outlyingness + weighted_dir_outlyingness = (dir_outlyingness + * pointwise_weights[:, np.newaxis]) + assert weighted_dir_outlyingness.shape == dir_outlyingness.shape + + mean_dir_outlyingness = scipy.integrate.simps(weighted_dir_outlyingness, + fdatagrid.sample_points[0], + axis=1) + assert mean_dir_outlyingness.shape == ( + fdatagrid.nsamples, fdatagrid.ndim_codomain) + + # Calculation variation directional outlyingness + norm = np.square(la.norm(dir_outlyingness - + mean_dir_outlyingness[:, np.newaxis, :], axis=-1)) + weighted_norm = norm * pointwise_weights + variation_dir_outlyingness = scipy.integrate.simps( + weighted_norm, fdatagrid.sample_points[0], axis=1) + assert variation_dir_outlyingness.shape == (fdatagrid.nsamples,) + + functional_dir_outlyingness = (np.square(la.norm(mean_dir_outlyingness)) + + variation_dir_outlyingness) + assert functional_dir_outlyingness.shape == (fdatagrid.nsamples,) + + return DirectionalOutlyingnessStats( + directional_outlyingness=dir_outlyingness, + functional_directional_outlyingness=functional_dir_outlyingness, + mean_directional_outlyingness=mean_dir_outlyingness, + variation_directional_outlyingness=variation_dir_outlyingness) diff --git a/skfda/exploratory/visualization/magnitude_shape_plot.py b/skfda/exploratory/visualization/magnitude_shape_plot.py index 3e1bf29af..b4ef8a044 100644 --- a/skfda/exploratory/visualization/magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/magnitude_shape_plot.py @@ -9,9 +9,6 @@ from io import BytesIO import matplotlib -from numpy import linalg as la -import scipy -import scipy.integrate from scipy.stats import f, variation from sklearn.covariance import MinCovDet @@ -19,209 +16,13 @@ import numpy as np from skfda.exploratory.depth import modified_band_depth -from ... import FDataGrid +from ..outliers import directional_outlyingness_stats __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" -def directional_outlyingness(fdatagrid, depth_method=modified_band_depth, - dim_weights=None, pointwise_weights=None): - r"""Computes the directional outlyingness of the functional data. - - Furthermore, it calculates both the mean and the variation of the - directional outlyingness of the samples in the data set, which are also - returned. - - The first one, the mean directional outlyingness, describes the relative - position (including both distance and direction) of the samples on average - to the center curve; its norm can be regarded as the magnitude - outlyingness. - - The second one, the variation of the directional outlyingness, measures - the change of the directional outlyingness in terms of both norm and - direction across the whole design interval and can be regarded as the - shape outlyingness. - - Firstly, the directional outlyingness is calculated as follows: - - .. math:: - \mathbf{O}\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right) = - \left\{\frac{1}{d\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right)} - 1 - \right\} \cdot \mathbf{v}(t) - - where :math:`\mathbf{X}` is a stochastic process with probability - distribution :math:`F`, :math:`d` a depth function and :math:`\mathbf{v}(t) - = \left\{ \mathbf{X}(t) - \mathbf{Z}(t)\right\} / \lVert \mathbf{X}(t) - - \mathbf{Z}(t) \rVert` is the spatial sign of :math:`\left\{\mathbf{X}(t) - - \mathbf{Z}(t)\right\}`, :math:`\mathbf{Z}(t)` denotes the median and - :math:`\lVert \cdot \rVert` denotes the :math:`L_2` norm. - - From the above formula, we define the mean directional outlyingness as: - - .. math:: - \mathbf{MO}\left(\mathbf{X} , F_{\mathbf{X}}\right) = \int_I - \mathbf{O}\left(\mathbf{X}(t) , F_{\mathbf{X}(t)}\right) \cdot w(t) dt; - - and the variation of the directional outlyingness as: - - .. math:: - VO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \int_I \lVert\mathbf{O} - \left(\mathbf{X}(t), F_{\mathbf{X}(t)}\right)-\mathbf{MO}\left( - \mathbf{X} , F_{\mathbf{X}}\right) \rVert^2 \cdot w(t) dt - - where :math:`w(t)` a weight function defined on the domain of - :math:`\mathbf{X}`, :math:`I`. - - Then, the total functional outlyingness can be computed using these values: - - .. math:: - FO\left(\mathbf{X} , F_{\mathbf{X}}\right) = \lVert \mathbf{MO}\left( - \mathbf{X} , F_{\mathbf{X}}\right)\rVert^2 + VO\left(\mathbf{X} , - F_{\mathbf{X}}\right) . - - Args: - fdatagrid (FDataGrid): Object containing the samples to be ordered - according to the directional outlyingness. - depth_method (:ref:`depth measure `, optional): Method - used to order the data. Defaults to :func:`modified band depth - `. - dim_weights (array_like, optional): an array containing the weights of - each of the dimensions of the image. Defaults to the same weight - for each of the dimensions: 1/ndim_image. - pointwise_weights (array_like, optional): an array containing the - weights of each point of discretisation where values have been - recorded. Defaults to the same weight for each of the points: - 1/len(interval). - - Returns: - (tuple): tuple containing: - - dir_outlyingness (numpy.array((fdatagrid.shape))): List containing - the values of the directional outlyingness of the FDataGrid object. - - mean_dir_outl (numpy.array((fdatagrid.nsamples, 2))): List - containing the values of the magnitude outlyingness for each of - the samples. - - variation_dir_outl (numpy.array((fdatagrid.nsamples,))): List - containing the values of the shape outlyingness for each of - the samples. - - Example: - - >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], - ... [0.5, 0.5, 1, 2, 1.5, 1], - ... [-1, -1, -0.5, 1, 1, 0.5], - ... [-0.5, -0.5, -0.5, -1, -1, -1]] - >>> sample_points = [0, 2, 4, 6, 8, 10] - >>> fd = FDataGrid(data_matrix, sample_points) - >>> directional_outlyingness(fd) - (array([[[ 1. ], - [ 1. ], - [ 1. ], - [ 1. ], - [ 1. ], - [ 1. ]], - - [[ 0. ], - [ 0. ], - [ 0. ], - [ 0. ], - [ 0. ], - [ 0. ]], - - [[-1. ], - [-1. ], - [-0.2], - [-0.2], - [-0.2], - [-0.2]], - - [[-0.2], - [-0.2], - [-0.2], - [-1. ], - [-1. ], - [-1. ]]]), array([[ 1.66666667], - [ 0. ], - [-0.73333333], - [-1. ]]), array([ 0.74074074, 0. , 0.36740741, - 0.53333333])) - - - """ - - if fdatagrid.ndim_domain > 1: - raise NotImplementedError("Only support 1 dimension on the domain.") - - if dim_weights is not None and (len( - dim_weights) != fdatagrid.ndim_image or dim_weights.sum() != 1): - raise ValueError( - "There must be a weight in dim_weights for each dimension of the " - "image and altogether must sum 1.") - - if (pointwise_weights is not None and - (len(pointwise_weights) != fdatagrid.ncol or - pointwise_weights.sum() != 1)): - raise ValueError( - "There must be a weight in pointwise_weights for each recorded " - "time point and altogether must sum 1.") - - depth, depth_pointwise = depth_method(fdatagrid, pointwise=True) - - if dim_weights is None: - dim_weights = np.ones(fdatagrid.ndim_image) / fdatagrid.ndim_image - - if pointwise_weights is None: - pointwise_weights = np.ones(fdatagrid.ncol) / fdatagrid.ncol - - # Calculation of the depth of each multivariate sample with the - # corresponding weight. - weighted_depth = depth * dim_weights - sample_depth = weighted_depth.sum(axis=-1) - - # Obtaining the median sample Z, to caculate - # v(t) = {X(t) − Z(t)}/∥ X(t) − Z(t)∥ - median_index = np.argmax(sample_depth) - median = fdatagrid.data_matrix[median_index] - v = fdatagrid.data_matrix - median - v_norm = la.norm(v, axis=-1, keepdims=True) - # To avoid ZeroDivisionError, the zeros are substituted by ones. - v_norm[np.where(v_norm == 0)] = 1 - v_unitary = v / v_norm - - # Calculation of the depth of each point of each sample with - # the corresponding weight. - weighted_depth_pointwise = depth_pointwise * dim_weights - sample_depth_pointwise = weighted_depth_pointwise.sum(axis=-1, - keepdims=True) - - # Calcuation directinal outlyingness - dir_outlyingness = (1 / sample_depth_pointwise - 1) * v_unitary - - # Calcuation mean directinal outlyingness - pointwise_weights_1 = np.tile(pointwise_weights, - (fdatagrid.ndim_image, 1)).T - weighted_dir_outlyingness = dir_outlyingness * pointwise_weights_1 - mean_dir_outl = scipy.integrate.simps(weighted_dir_outlyingness, - fdatagrid.sample_points[0], - axis=1) - - # Calcuation variation directinal outlyingness - mean_dir_outl_pointwise = np.repeat(mean_dir_outl, fdatagrid.ncol, - axis=0).reshape(fdatagrid.shape) - norm = np.square( - la.norm(dir_outlyingness - mean_dir_outl_pointwise, axis=-1)) - weighted_norm = norm * pointwise_weights - variation_dir_outl = scipy.integrate.simps(weighted_norm, - fdatagrid.sample_points[0], - axis=1) - - return dir_outlyingness, mean_dir_outl, variation_dir_outl - - class MagnitudeShapePlot: r"""Implementation of the magnitude-shape plot @@ -310,12 +111,13 @@ class MagnitudeShapePlot: Example: + >>> import skfda >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], ... [-1, -1, -0.5, 1, 1, 0.5], ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] - >>> fd = FDataGrid(data_matrix, sample_points) + >>> fd = skfda.FDataGrid(data_matrix, sample_points) >>> MagnitudeShapePlot(fd) MagnitudeShapePlot( FDataGrid=FDataGrid( @@ -355,7 +157,6 @@ class MagnitudeShapePlot: smoothness_parameter=0.0, monotone=False), keepdims=False), depth_method=modified_band_depth, - dim_weights=None, pointwise_weights=None, alpha=0.993, points=array([[ 1.66666667, 0.74074074], @@ -372,7 +173,7 @@ class MagnitudeShapePlot: """ def __init__(self, fdatagrid, depth_method=modified_band_depth, - dim_weights=None, pointwise_weights=None, alpha=0.993, + pointwise_weights=None, alpha=0.993, assume_centered=False, support_fraction=None, random_state=0): """Initialization of the MagnitudeShapePlot class. @@ -381,8 +182,6 @@ def __init__(self, fdatagrid, depth_method=modified_band_depth, depth_method (:ref:`depth measure `, optional): Method used to order the data. Defaults to :func:`modified band depth `. - dim_weights (array_like, optional): an array containing the weights - of each of the dimensions of the image. pointwise_weights (array_like, optional): an array containing the weights of each points of discretisati on where values have been recorded. @@ -414,14 +213,17 @@ def __init__(self, fdatagrid, depth_method=modified_band_depth, raise NotImplementedError("Only support 1 dimension on the image.") # The depths of the samples are calculated giving them an ordering. - _, mean_dir_outl, variation_dir_outl = directional_outlyingness( + *_, mean_dir_outl, variation_dir_outl = directional_outlyingness_stats( fdatagrid, depth_method, - dim_weights, pointwise_weights) - points = np.array(list(zip(mean_dir_outl, variation_dir_outl))).astype( - float) + points = np.array( + list( + zip( + mean_dir_outl.ravel(), variation_dir_outl + ) + )) # The square mahalanobis distances of the samples are # calulated using MCD. @@ -447,7 +249,6 @@ def __init__(self, fdatagrid, depth_method=modified_band_depth, self._fdatagrid = fdatagrid self._depth_method = depth_method - self._dim_weights = dim_weights self._pointwise_weights = pointwise_weights self._alpha = alpha self._mean_dir_outl = mean_dir_outl @@ -469,10 +270,6 @@ def fdatagrid(self): def depth_method(self): return self._depth_method - @property - def dim_weights(self): - return self._dim_weights - @property def pointwise_weights(self): return self._pointwise_weights @@ -543,7 +340,7 @@ def plot(self, ax=None): ax = matplotlib.pyplot.gca() colors_rgba = [tuple(i) for i in colors] - ax.scatter(self._mean_dir_outl, self._variation_dir_outl, + ax.scatter(self._mean_dir_outl.ravel(), self._variation_dir_outl, color=colors_rgba) ax.set_xlabel(self.xlabel) @@ -557,7 +354,6 @@ def __repr__(self): return (f"MagnitudeShapePlot(" f"\nFDataGrid={repr(self.fdatagrid)}," f"\ndepth_method={self.depth_method.__name__}," - f"\ndim_weights={repr(self.dim_weights)}," f"\npointwise_weights={repr(self.pointwise_weights)}," f"\nalpha={repr(self.alpha)}," f"\npoints={repr(self.points)}," diff --git a/tests/test_magnitude_shape.py b/tests/test_magnitude_shape.py index ac9017a58..a864b3c83 100644 --- a/tests/test_magnitude_shape.py +++ b/tests/test_magnitude_shape.py @@ -2,47 +2,13 @@ import numpy as np from skfda import FDataGrid -from skfda.exploratory.visualization.magnitude_shape_plot import ( - directional_outlyingness, MagnitudeShapePlot) from skfda.datasets import fetch_weather +from skfda.exploratory.visualization.magnitude_shape_plot import ( + MagnitudeShapePlot) class TestMagnitudeShapePlot(unittest.TestCase): - # def setUp(self): could be defined for set up before any test - - def test_directional_outlyingness(self): - data_matrix = [[[1, 0.3], [2, 0.4], [3, 0.5], [4, 0.6]], - [[2, 0.5], [3, 0.6], [4, 0.7], [5, 0.7]], - [[3, 0.2], [4, 0.3], [5, 0.4], [6, 0.5]]] - sample_points = [2, 4, 6, 8] - fd = FDataGrid(data_matrix, sample_points) - dir_outlyingness, mean_dir_outl, variation_dir_outl = directional_outlyingness( - fd) - np.testing.assert_allclose(dir_outlyingness, - np.array([[[0., 0.], - [0., 0.], - [0., 0.], - [0., 0.]], - - [[0.19611614, 0.03922323], - [0.19611614, 0.03922323], - [0.19611614, 0.03922323], - [0.19900744, 0.01990074]], - - [[0.49937617, -0.02496881], - [0.49937617, -0.02496881], - [0.49937617, -0.02496881], - [0.49937617, -0.02496881]]]), - rtol=1e-06) - np.testing.assert_allclose(mean_dir_outl, - np.array([[0., 0.], - [0.29477656, 0.05480932], - [0.74906425, -0.03745321]]), - rtol=1e-06) - np.testing.assert_allclose(variation_dir_outl, - np.array([0., 0.01505136, 0.09375])) - def test_magnitude_shape_plot(self): fd = fetch_weather()["data"] fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], @@ -51,47 +17,47 @@ def test_magnitude_shape_plot(self): axes_labels=fd.axes_labels[0:2]) msplot = MagnitudeShapePlot(fd_temperatures, random_state=0) np.testing.assert_allclose(msplot.points, - np.array([[0.28216472, 3.15069249], - [1.43406267, 0.77729052], - [0.96089808, 2.7302293], - [2.1469911, 7.06601804], - [0.89081951, 0.71098079], - [1.22591999, 0.2363983], - [-2.65530111, 0.9666511], - [0.47819535, 0.83989187], - [-0.11256072, 0.89035836], - [0.99627103, 0.3255725], - [0.77889317, 0.32451932], - [3.47490723, 12.5630275], - [3.14828582, 13.80605804], - [3.51793514, 10.46943904], - [3.94435195, 15.24142224], - [0., 0.], - [0.74574282, 6.68207165], - [-0.82501844, 0.82694929], - [-3.4617439, 1.10389229], - [0.44523944, 1.61262494], - [-0.52255157, 1.00486028], - [-1.67260144, 0.74626351], - [-0.10133788, 0.96326946], - [0.36576472, 0.93071675], - [7.57827303, 40.70985885], - [7.51140842, 36.65641988], - [7.13000185, 45.56574331], - [0.28166597, 1.70861091], - [1.55486533, 8.75149947], - [-1.43363018, 0.36935927], - [-2.79138743, 4.80007762], - [-2.39987853, 1.54992208], - [-5.87118328, 5.34300766], - [-5.42854833, 5.1694065], - [-16.34459211, 0.9397118]])) + np.array([[0.25839562, 3.14995827], + [1.3774155, 0.91556716], + [0.94389069, 2.74940766], + [2.10767177, 7.22065509], + [0.82331252, 0.8250163], + [1.22912089, 0.2194518], + [-2.65530111, 0.9666511], + [0.15784599, 0.99960958], + [-0.43631897, 0.66055387], + [0.70501476, 0.66301126], + [0.72895263, 0.33074653], + [3.47490723, 12.5630275], + [3.14674773, 13.81447167], + [3.51793514, 10.46943904], + [3.94435195, 15.24142224], + [-0.48353674, 0.50215652], + [0.64316089, 6.81513544], + [-0.82957845, 0.80903798], + [-3.4617439, 1.10389229], + [0.2218012, 1.76299192], + [-0.54253359, 0.94968438], + [-1.70841274, 0.61708188], + [-0.44040451, 0.77602089], + [0.13813459, 1.02279698], + [7.57827303, 40.70985885], + [7.55791925, 35.94093086], + [7.10977399, 45.84310211], + [0.05730784, 1.75335899], + [1.52672644, 8.82803475], + [-1.48288999, 0.22412958], + [-2.84526533, 4.49585828], + [-2.41633786, 1.46528758], + [-5.87118328, 5.34300766], + [-5.42854833, 5.1694065], + [-16.34459211, 0.9397118]])) np.testing.assert_array_almost_equal(msplot.outliers, np.array( [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, - 0, 0, 0, 0, 1])) + 0, 0, 1, 0, 1])) if __name__ == '__main__': diff --git a/tests/test_outliers.py b/tests/test_outliers.py new file mode 100644 index 000000000..d2ebd7037 --- /dev/null +++ b/tests/test_outliers.py @@ -0,0 +1,44 @@ +import unittest + +import numpy as np +from skfda import FDataGrid +from skfda.exploratory.outliers import directional_outlyingness_stats + + +class TestsDirectionalOutlyingness(unittest.TestCase): + + def test_directional_outlyingness(self): + data_matrix = [[[0.3], [0.4], [0.5], [0.6]], + [[0.5], [0.6], [0.7], [0.7]], + [[0.2], [0.3], [0.4], [0.5]]] + sample_points = [2, 4, 6, 8] + fd = FDataGrid(data_matrix, sample_points) + stats = directional_outlyingness_stats(fd) + np.testing.assert_allclose(stats.directional_outlyingness, + np.array([[[0.], + [0.], + [0.], + [0.]], + + [[1.], + [1.], + [1.], + [1.]], + + [[-0.2], + [-0.2], + [-0.2], + [-0.2]]]), + rtol=1e-06) + np.testing.assert_allclose(stats.mean_directional_outlyingness, + np.array([[0.], + [1.5], + [-0.3]]), + rtol=1e-06) + np.testing.assert_allclose(stats.variation_directional_outlyingness, + np.array([0., 0.375, 0.015])) + + +if __name__ == '__main__': + print() + unittest.main() From b26ba2913d1fd8e84abbb8042f7a5c53fe9adfff Mon Sep 17 00:00:00 2001 From: vnmabus Date: Sat, 10 Aug 2019 00:28:10 +0200 Subject: [PATCH 169/222] Correct call to BasisSmoother with keepdims. --- skfda/representation/basis.py | 1 - 1 file changed, 1 deletion(-) diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index c52ec0879..5f82b623d 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1679,7 +1679,6 @@ def from_data(cls, data_matrix, sample_points, basis, smoother = BasisSmoother( basis=basis, method=method, - keepdims=keepdims, return_basis=True) return smoother.fit_transform(fd) From 92989d41938ef5f3a162667e166c246d6e070202 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 14 Aug 2019 22:28:10 +0200 Subject: [PATCH 170/222] Add IqrOutlierDetector --- skfda/exploratory/outliers/_iqr.py | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 skfda/exploratory/outliers/_iqr.py diff --git a/skfda/exploratory/outliers/_iqr.py b/skfda/exploratory/outliers/_iqr.py new file mode 100644 index 000000000..dc6af8193 --- /dev/null +++ b/skfda/exploratory/outliers/_iqr.py @@ -0,0 +1,61 @@ +from sklearn.base import BaseEstimator, OutlierMixin + +from . import _envelopes +from ..depth import modified_band_depth + + +class IQROutlierDetector(BaseEstimator, OutlierMixin): + r"""Outlier detector using the interquartilic range. + + Detects as outliers functions that have one or more points outside + ``factor`` times the interquartilic range plus or minus the central + envelope, given a functional depth measure. This corresponds to the + points selected as outliers by the functional boxplot. + + Parameters: + depth (Callable): The functional depth measure used. + factor (float): The number of times the IQR is multiplied. + + Example: + Function :math:`f : \mathbb{R}\longmapsto\mathbb{R}`. + + >>> import skfda + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> sample_points = [0, 2, 4, 6, 8, 10] + >>> fd = skfda.FDataGrid(data_matrix, sample_points) + >>> out_detector = IQROutlierDetector() + >>> out_detector.fit_predict(fd) + array([-1, 1, 1, -1]) + + """ + + def __init__(self, *, depth=modified_band_depth, factor=1.5): + self.depth = depth + self.factor = factor + + def fit(self, X, y=None): + depth = self.depth(X) + indices_descending_depth = (-depth).argsort(axis=0) + + # Central region and envelope must be computed for outlier detection + central_region = _envelopes._compute_region( + X, indices_descending_depth, 0.5) + self._central_envelope = _envelopes._compute_envelope(central_region) + + # Non-outlying envelope + self.non_outlying_threshold_ = _envelopes._non_outlying_threshold( + self._central_envelope, self.factor) + + return self + + def predict(self, X, y=None): + outliers = _envelopes._predict_outliers( + X, self.non_outlying_threshold_) + + # Predict as scikit-learn outlier detectors + predicted = ~outliers + outliers * -1 + + return predicted From f83e4dca0187a2f5282b6544e0f2d7a96f45ecd7 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 22 Aug 2019 02:20:40 +0200 Subject: [PATCH 171/222] First version of outlier detection --- docs/modules/exploratory.rst | 3 +- docs/modules/exploratory/outliers.rst | 36 ++++ .../visualization/magnitude_shape_plot.rst | 8 +- examples/plot_boxplot.py | 110 ++++++------ examples/plot_magnitude_shape.py | 85 ++++----- skfda/exploratory/outliers/__init__.py | 4 +- .../outliers/_directional_outlyingness.py | 166 ++++++++++++++++++ skfda/exploratory/outliers/_iqr.py | 10 +- skfda/exploratory/visualization/boxplot.py | 9 +- .../visualization/magnitude_shape_plot.py | 112 ++---------- tests/test_fdata_boxplot.py | 2 +- 11 files changed, 339 insertions(+), 206 deletions(-) create mode 100644 docs/modules/exploratory/outliers.rst diff --git a/docs/modules/exploratory.rst b/docs/modules/exploratory.rst index 8022f90ed..45f048bfa 100644 --- a/docs/modules/exploratory.rst +++ b/docs/modules/exploratory.rst @@ -9,4 +9,5 @@ and visualize functional data. :caption: Modules: exploratory/visualization - exploratory/depth \ No newline at end of file + exploratory/depth + exploratory/outliers \ No newline at end of file diff --git a/docs/modules/exploratory/outliers.rst b/docs/modules/exploratory/outliers.rst new file mode 100644 index 000000000..2b2a00087 --- /dev/null +++ b/docs/modules/exploratory/outliers.rst @@ -0,0 +1,36 @@ +Outlier detection +================= + +Functional outlier detection is the identification of functions that do not seem to behave like the others in the +dataset. There are several ways in which a function may be different from the others. For example, a function may +have a different shape than the others, or its values could be more extreme. Thus, outlyingness is difficult to +categorize exactly as each outlier detection method looks at different features of the functions in order to +identify the outliers. + +Each of the outlier detection methods in scikit-fda has the same API as the outlier detection methods of +`scikit-learn `. + +One of the most common ways of outlier detection is given by the functional data boxplot. An observation is marked +as an outlier if it has points :math:`1.5 \cdot IQR` times outside the region containing the deepest 50% of the curves +(the central region), where :math:`IQR` is the interquartilic range. + +.. autosummary:: + :toctree: autosummary + + skfda.exploratory.outliers.IQROutlierDetector + +Other more novel way of outlier detection takes into account the magnitude and shape of the curves. Curves which have +a very different shape or magnitude are considered outliers. + +.. autosummary:: + :toctree: autosummary + + skfda.exploratory.outliers.DirectionalOutlierDetector + +For this method, it is necessary to compute the mean and variation of the directional outlyingness, which can be done +with the following function. + +.. autosummary:: + :toctree: autosummary + + skfda.exploratory.outliers.directional_outlyingness_stats \ No newline at end of file diff --git a/docs/modules/exploratory/visualization/magnitude_shape_plot.rst b/docs/modules/exploratory/visualization/magnitude_shape_plot.rst index 827f3718e..f6330adac 100644 --- a/docs/modules/exploratory/visualization/magnitude_shape_plot.rst +++ b/docs/modules/exploratory/visualization/magnitude_shape_plot.rst @@ -4,12 +4,8 @@ Magnitude-Shape Plot The Magnitude-Shape Plot is implemented in the :class:`MagnitudeShapePlot` class. The :class:`MagnitudeShapePlot` needs both the mean and the variation of the -directional outlyingness of the samples, which is calculated in the below function. - -.. autosummary:: - :toctree: autosummary - - skfda.exploratory.visualization.magnitude_shape_plot.directional_outlyingness +directional outlyingness of the samples, which is calculated using +:func:`directional_outlyingness_stats`. Once the points assigned to each of the samples are obtained from the above function, an outlier detection method is implemented. The results can be shown diff --git a/examples/plot_boxplot.py b/examples/plot_boxplot.py index a70d22807..3d6188d05 100644 --- a/examples/plot_boxplot.py +++ b/examples/plot_boxplot.py @@ -2,7 +2,8 @@ Boxplot ======= -Shows the use of the functional Boxplot applied to the Canadian Weather dataset. +Shows the use of the functional Boxplot applied to the Canadian Weather +dataset. """ # Author: Amanda Hernando Bernabé @@ -12,27 +13,24 @@ import matplotlib.pyplot as plt import numpy as np -from skfda import FDataGrid from skfda import datasets from skfda.exploratory.depth import band_depth, fraiman_muniz_depth from skfda.exploratory.visualization.boxplot import Boxplot -########################################################################## -# First, the Canadian Weather dataset is downloaded from the package 'fda' in CRAN. -# It contains a FDataGrid with daily temperatures and precipitations, that is, it -# has a 2-dimensional image. We are interested only in the daily average temperatures, -# so another FDataGrid is constructed with the desired values. +############################################################################## +# First, the Canadian Weather dataset is downloaded from the package 'fda' in +# CRAN. It contains a FDataGrid with daily temperatures and precipitations, +# that is, it has a 2-dimensional image. We are interested only in the daily +# average temperatures, so we will use the first coordinate. dataset = datasets.fetch_weather() fd = dataset["data"] -fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], - sample_points=fd.sample_points, - dataset_label=fd.dataset_label, - axes_labels=fd.axes_labels[0:2]) -########################################################################## -# The data is plotted to show the curves we are working with. They are divided according to the -# target. In this case, it includes the different climates to which the -# weather stations belong to. +fd_temperatures = fd.coordinates[0] + +############################################################################## +# The data is plotted to show the curves we are working with. They are divided +# according to the target. In this case, it includes the different climates to +# which the weather stations belong to. # Each climate is assigned a color. Defaults to grey. colormap = plt.cm.get_cmap('seismic') @@ -41,17 +39,19 @@ label_colors = colormap(np.arange(nlabels) / (nlabels - 1)) plt.figure() -fd_temperatures.plot(sample_labels=dataset["target"], label_colors=label_colors, +fd_temperatures.plot(sample_labels=dataset["target"], + label_colors=label_colors, label_names=label_names) -########################################################################## -# We instantiate a :func:`functional boxplot object ` with the data, -# and we call its :func:`plot function ` to show the graph. +############################################################################## +# We instantiate a :func:`functional boxplot object ` +# with the data, and we call its +# :func:`plot function ` to show the graph. # -# By default, only the part of the outlier curves which falls out of the central regions -# is plotted. We want the entire curve to be shown, that is why the show_full_outliers parameter is -# set to True. +# By default, only the part of the outlier curves which falls out of the +# central regions is plotted. We want the entire curve to be shown, that is +# why the show_full_outliers parameter is set to True. fdBoxplot = Boxplot(fd_temperatures) fdBoxplot.show_full_outliers = True @@ -59,11 +59,12 @@ plt.figure() fdBoxplot.plot() -########################################################################## -# We can observe in the boxplot the median in black, the central region (where the 50% of the -# most centered samples reside) in pink and the envelopes and vertical lines in blue. The -# outliers detected, those samples with at least a point outside the outlying envelope, are -# represented with a red dashed line. The colors can be customized. +############################################################################## +# We can observe in the boxplot the median in black, the central region (where +# the 50% of the most centered samples reside) in pink and the envelopes and +# vertical lines in blue. The outliers detected, those samples with at least a +# point outside the outlying envelope, are represented with a red dashed line. +# The colors can be customized. # # The outliers are shown below with respect to the other samples. @@ -75,42 +76,45 @@ label_colors=colormap([color, outliercol]), label_names=["nonoutliers", "outliers"]) -########################################################################## -# The curves pointed as outliers are are those curves with significantly lower values to the -# rest. This is the expected result due to the depth measure used, the :func:`modified band -# depth ` which rank the samples according to -# their magnitude. +############################################################################## +# The curves pointed as outliers are are those curves with significantly lower +# values than the rest. This is the expected result due to the depth measure +# used, the :func:`modified band depth +# ` which rank the samples +# according to their magnitude. # -# The :func:`functional boxplot object ` admits any :ref:`depth measure -# ` defined or customized by the user. Now the call is done with the -# :func:`band depth measure ` and the factor is reduced -# in order to designate some samples as outliers (otherwise, with this measure and the default -# factor, none of the curves are pointed out as outliers). We can see that the outliers detected -# belong to the Pacific and Arctic climates which are less common to find in Canada. As a -# consequence, this measure detects better shape outliers compared to the -# previous one. - -fdBoxplot = Boxplot(fd_temperatures, method=band_depth, factor=0.4) +# The :func:`functional boxplot object ` admits any +# :ref:`depth measure ` defined or customized by the user. Now +# the call is done with the :func:`band depth measure +# ` and the factor is reduced +# in order to designate some samples as outliers (otherwise, with this measure +# and the default factor, none of the curves are pointed out as outliers). We +# can see that the outliers detected belong to the Pacific and Arctic climates +# which are less common to find in Canada. As a consequence, this measure +# detects better shape outliers compared to the previous one. + +fdBoxplot = Boxplot(fd_temperatures, depth_method=band_depth, factor=0.4) fdBoxplot.show_full_outliers = True plt.figure() fdBoxplot.plot() -########################################################################## -# Another functionality implemented in this object is the enhanced functional boxplot, -# which can include other central regions, apart from the central or 50% one. +############################################################################## +# Another functionality implemented in this object is the enhanced functional +# boxplot, which can include other central regions, apart from the central or +# 50% one. # # In the following instantiation, the :func:`Fraiman and Muniz depth measure -# ` is used and the 25% and 75% central regions -# are specified. +# ` is used and the 25% and +# 75% central regions are specified. -fdBoxplot = Boxplot(fd_temperatures, method=fraiman_muniz_depth, +fdBoxplot = Boxplot(fd_temperatures, depth_method=fraiman_muniz_depth, prob=[0.75, 0.5, 0.25]) plt.figure() fdBoxplot.plot() -########################################################################## -# The above two lines could be replaced just by fdBoxplot since the default representation of -# the :func:`boxplot object ` is the image of the plot. However, due to -# generation of this notebook it does not show the image and that is why the plot method is -# called. +############################################################################## +# The above two lines could be replaced just by fdBoxplot since the default +# representation of the :func:`boxplot object ` is the +# image of the plot. However, due to generation of this notebook it does not +# show the image and that is why the plot method is called. diff --git a/examples/plot_magnitude_shape.py b/examples/plot_magnitude_shape.py index b574266ba..2749abd7f 100644 --- a/examples/plot_magnitude_shape.py +++ b/examples/plot_magnitude_shape.py @@ -10,29 +10,27 @@ # sphinx_gallery_thumbnail_number = 2 +import matplotlib.pyplot as plt +import numpy as np from skfda import datasets -from skfda import FDataGrid from skfda.exploratory.depth import fraiman_muniz_depth from skfda.exploratory.visualization.magnitude_shape_plot import ( MagnitudeShapePlot) -import matplotlib.pyplot as plt -import numpy as np -################################################################################## -# First, the Canadian Weather dataset is downloaded from the package 'fda' in CRAN. -# It contains a FDataGrid with daily temperatures and precipitations, that is, it -# has a 2-dimensional image. We are interested only in the daily average temperatures, -# so another FDataGrid is constructed with the desired values. +############################################################################## +# First, the Canadian Weather dataset is downloaded from the package 'fda' in +# CRAN. It contains a FDataGrid with daily temperatures and precipitations, +# that is, it has a 2-dimensional image. We are interested only in the daily +# average temperatures, so we extract the first coordinate. dataset = datasets.fetch_weather() fd = dataset["data"] -fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], - sample_points=fd.sample_points, - dataset_label=fd.dataset_label, - axes_labels=fd.axes_labels[0:2]) -############################################################################################ -# The data is plotted to show the curves we are working with. They are divided according to the -# target. In this case, it includes the different climates to which the weather stations belong to. +fd_temperatures = fd.coordinates[0] + +############################################################################## +# The data is plotted to show the curves we are working with. They are divided +# according to the target. In this case, it includes the different climates to +# which the weather stations belong. # Each climate is assigned a color. Defaults to grey. colormap = plt.cm.get_cmap('seismic') @@ -41,14 +39,16 @@ label_colors = colormap(np.arange(nlabels) / (nlabels - 1)) plt.figure() -fd_temperatures.plot(sample_labels=dataset["target"], label_colors=label_colors, +fd_temperatures.plot(sample_labels=dataset["target"], + label_colors=label_colors, label_names=label_names) -############################################################################################# +############################################################################## # The MS-Plot is generated. In order to show the results, the -# :func:`plot method ` is used. Note that the -# colors have been specified before to distinguish between outliers or not. In particular the tones -# of the default colormap, (which is 'seismic' and can be customized), are assigned. +# :func:`plot method ` +# is used. Note that the colors have been specified before to distinguish +# between outliers or not. In particular the tones of the default colormap, +# (which is 'seismic' and can be customized), are assigned. msplot = MagnitudeShapePlot(fdatagrid=fd_temperatures) @@ -60,42 +60,45 @@ msplot.outliercol = outliercol msplot.plot() -############################################################################################ -# To show the utility of the plot, the curves are plotted according to the distinction -# made by the MS-Plot (outliers or not) with the same colors. +############################################################################## +# To show the utility of the plot, the curves are plotted according to the +# distinction made by the MS-Plot (outliers or not) with the same colors. plt.figure() -fd_temperatures.plot(sample_labels=msplot.outliers, +fd_temperatures.plot(sample_labels=msplot.outliers.astype(int), label_colors=msplot.colormap([color, outliercol]), - label_names = ['nonoutliers', 'outliers']) + label_names=['nonoutliers', 'outliers']) -####################################################################################### -# We can observe that most of the curves pointed as outliers belong either to the Pacific or -# Arctic climates which are not the common ones found in Canada. The Pacific temperatures -# are much smoother and the Arctic ones much lower, differing from the rest in shape and -# magnitude respectively. +############################################################################## +# We can observe that most of the curves pointed as outliers belong either to +# the Pacific or Arctic climates which are not the common ones found in +# Canada. The Pacific temperatures are much smoother and the Arctic ones much +# lower, differing from the rest in shape and magnitude respectively. # # There are two curves from the Arctic climate which are not pointed as -# outliers but in the MS-Plot, they appear further left from the central points. This behaviour -# can be modified specifying the parameter alpha. +# outliers but in the MS-Plot, they appear further left from the central +# points. This behaviour can be modified specifying the parameter alpha. # -# Now we use the :func:`Fraiman and Muniz depth measure ` +# Now we use the +# :func:`Fraiman and Muniz depth measure ` # in the MS-Plot. msplot = MagnitudeShapePlot(fdatagrid=fd_temperatures, - depth_method = fraiman_muniz_depth) + depth_method=fraiman_muniz_depth) plt.figure() msplot.color = color msplot.outliercol = outliercol msplot.plot() -####################################################################################### -# We can observe that none of the samples are pointed as outliers. Nevertheless, if we group them -# in three groups according to their position in the MS-Plot, the result is the expected one. -# Those samples at the left (larger deviation in the mean directional outlyingness) correspond -# to the Arctic climate, which has lower temperatures, and those on top (larger deviation in the -# directional outlyingness) to the Pacific one, which has smoother curves. +############################################################################## +# We can observe that none of the samples are pointed as outliers. +# Nevertheless, if we group them in three groups according to their position +# in the MS-Plot, the result is the expected one. Those samples at the left +# (larger deviation in the mean directional outlyingness) correspond to the +# Arctic climate, which has lower temperatures, and those on top (larger +# deviation in the directional outlyingness) to the Pacific one, which has +# smoother curves. group1 = np.where(msplot.points[:, 0] < -0.6) group2 = np.where(msplot.points[:, 1] > 0.12) @@ -111,7 +114,7 @@ plt.xlabel("magnitude outlyingness") plt.ylabel("shape outlyingness") -labels = np.copy(msplot.outliers) +labels = np.copy(msplot.outliers.astype(int)) labels[group1] = 1 labels[group2] = 2 diff --git a/skfda/exploratory/outliers/__init__.py b/skfda/exploratory/outliers/__init__.py index e66fde6d1..666ee83f6 100644 --- a/skfda/exploratory/outliers/__init__.py +++ b/skfda/exploratory/outliers/__init__.py @@ -1 +1,3 @@ -from ._directional_outlyingness import directional_outlyingness_stats +from ._directional_outlyingness import (directional_outlyingness_stats, + DirectionalOutlierDetector) +from ._iqr import IQROutlierDetector diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 135f09cb1..19ed7bd30 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -2,8 +2,12 @@ from numpy import linalg as la import scipy.integrate +from scipy.stats import f, variation +from sklearn.base import BaseEstimator, OutlierMixin +from sklearn.covariance import MinCovDet import numpy as np +from skfda.exploratory.depth import modified_band_depth from ... import FDataGrid from ..depth import fraiman_muniz_depth @@ -203,3 +207,165 @@ def directional_outlyingness_stats( functional_directional_outlyingness=functional_dir_outlyingness, mean_directional_outlyingness=mean_dir_outlyingness, variation_directional_outlyingness=variation_dir_outlyingness) + + +class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): + r"""Outlier detector using directional outlyingness. + + Considering :math:`\mathbf{Y} = \left(\mathbf{MO}^T, VO\right)^T`, the + outlier detection method is implemented as described below. + + First, the square robust Mahalanobis distance is calculated based on a + sample of size :math:`h \leq fdatagrid.nsamples`: + + .. math:: + {RMD}^2\left( \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right) = \left( + \mathbf{Y} - \mathbf{\tilde{Y}}^*_J\right)^T {\mathbf{S}^*_J}^{-1} + \left( \mathbf{Y} - \mathbf{\tilde{Y}}^*_J\right) + + where :math:`J` denotes the group of :math:`h` samples that minimizes the + determinant of the corresponding covariance matrix, + :math:`\mathbf{\tilde{Y}}^*_J = h^{-1}\sum_{i\in{J}}\mathbf{Y}_i` and + :math:`\mathbf{S}^*_J = h^{-1}\sum_{i\in{J}}\left( \mathbf{Y}_i - \mathbf{ + \tilde{Y}}^*_J\right) \left( \mathbf{Y}_i - \mathbf{\tilde{Y}}^*_J + \right)^T`. The sub-sample of size h controls the robustness of the method. + + Then, the tail of this distance distribution is approximated as follows: + + .. math:: + \frac{c\left(m - p\right)}{m\left(p + 1\right)}RMD^2\left( + \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right)\sim F_{p+1, m-p} + + where :math:`p` is the dmension of the image, and :math:`c` and :math:`m` + are parameters determining the degrees of freedom of the + :math:`F`-distribution and the scaling factor. + + .. math:: + c = E \left[s^*_{jj}\right] + + where :math:`s^*_{jj}` are the diagonal elements of MCD and + + .. math:: + m = \frac{2}{CV^2} + + where :math:`CV` is the estimated coefficient of variation of the diagonal + elements of the MCD shape estimator. + + Finally, we choose a cutoff value to determine the outliers, C , + as the :math:`\alpha` quantile of :math:`F_{p+1, m-p}`. We set + :math:`\alpha = 0.993`, which is used in the classical boxplot for + detecting outliers under a normal distribution. + + Parameters: + depth_method (:ref:`depth measure `, optional): + Method used to order the data. Defaults to :func:`modified band + depth `. + pointwise_weights (array_like, optional): an array containing the + weights of each points of discretisati on where values have + been recorded. + alpha (float, optional): Denotes the quantile to choose the cutoff + value for detecting outliers Defaults to 0.993, which is used + in the classical boxplot. + assume_centered (boolean, optional): If True, the support of the + robust location and the covariance estimates is computed, and a + covariance estimate is recomputed from it, without centering + the data. Useful to work with data whose mean is significantly + equal to zero but is not exactly zero. If False, default value, + the robust location and covariance are directly computed with + the FastMCD algorithm without additional treatment. + support_fraction (float, 0 < support_fraction < 1, optional): The + proportion of points to be included in the support of the + raw MCD estimate. + Default is None, which implies that the minimum value of + support_fraction will be used within the algorithm: + [n_sample + n_features + 1] / 2 + random_state (int, RandomState instance or None, optional): If int, + random_state is the seed used by the random number generator; + If RandomState instance, random_state is the random number + generator; If None, the random number generator is the + RandomState instance used by np.random. By default, it is 0. + + Example: + Function :math:`f : \mathbb{R}\longmapsto\mathbb{R}`. + + >>> import skfda + >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], + ... [0.5, 0.5, 1, 2, 1.5, 1], + ... [-1, -1, -0.5, 1, 1, 0.5], + ... [-0.5, -0.5, -0.5, -1, -1, -1]] + >>> sample_points = [0, 2, 4, 6, 8, 10] + >>> fd = skfda.FDataGrid(data_matrix, sample_points) + >>> out_detector = DirectionalOutlierDetector() + >>> out_detector.fit_predict(fd) + array([1, 1, 1, 1]) + + """ + + def __init__(self, *, depth_method=modified_band_depth, + pointwise_weights=None, + assume_centered=False, + support_fraction=None, + random_state=0, + alpha=0.993): + self.depth_method = depth_method + self.pointwise_weights = pointwise_weights + self.assume_centered = assume_centered + self.support_fraction = support_fraction + self.random_state = random_state + self.alpha = alpha + + def _compute_points(self, X): + # The depths of the samples are calculated giving them an ordering. + *_, mean_dir_outl, variation_dir_outl = directional_outlyingness_stats( + X, + self.depth_method, + self.pointwise_weights) + + points = np.array( + list( + zip( + mean_dir_outl.ravel(), variation_dir_outl + ) + )) + + return points + + def fit(self, X, y=None): + + self.points_ = self._compute_points(X) + + # The square mahalanobis distances of the samples are + # calulated using MCD. + self.cov_ = MinCovDet(store_precision=False, + assume_centered=self.assume_centered, + support_fraction=self.support_fraction, + random_state=self.random_state).fit(self.points_) + + # Calculation of the degrees of freedom of the F-distribution + # (approximation of the tail of the distance distribution). + s_jj = np.diag(self.cov_.covariance_) + c = np.mean(s_jj) + m = 2 / np.square(variation(s_jj)) + p = X.ndim_image + dfn = p + 1 + dfd = m - p + + # Calculation of the cutoff value and scaling factor to identify + # outliers. + self.cutoff_value_ = f.ppf(self.alpha, dfn, dfd, loc=0, scale=1) + self.scaling_ = c * dfd / m / dfn + + return self + + def predict(self, X): + + points = self._compute_points(X) + + rmd_2 = self.cov_.mahalanobis(points) + + outliers = self.scaling_ * rmd_2 > self.cutoff_value_ + + # Predict as scikit-learn outlier detectors + predicted = ~outliers + outliers * -1 + + return predicted diff --git a/skfda/exploratory/outliers/_iqr.py b/skfda/exploratory/outliers/_iqr.py index dc6af8193..d5a7ac6da 100644 --- a/skfda/exploratory/outliers/_iqr.py +++ b/skfda/exploratory/outliers/_iqr.py @@ -13,7 +13,7 @@ class IQROutlierDetector(BaseEstimator, OutlierMixin): points selected as outliers by the functional boxplot. Parameters: - depth (Callable): The functional depth measure used. + depth_method (Callable): The functional depth measure used. factor (float): The number of times the IQR is multiplied. Example: @@ -32,12 +32,12 @@ class IQROutlierDetector(BaseEstimator, OutlierMixin): """ - def __init__(self, *, depth=modified_band_depth, factor=1.5): - self.depth = depth + def __init__(self, *, depth_method=modified_band_depth, factor=1.5): + self.depth_method = depth_method self.factor = factor def fit(self, X, y=None): - depth = self.depth(X) + depth = self.depth_method(X) indices_descending_depth = (-depth).argsort(axis=0) # Central region and envelope must be computed for outlier detection @@ -51,7 +51,7 @@ def fit(self, X, y=None): return self - def predict(self, X, y=None): + def predict(self, X): outliers = _envelopes._predict_outliers( X, self.non_outlying_threshold_) diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/boxplot.py index 7e4dd7326..6439c9a57 100644 --- a/skfda/exploratory/visualization/boxplot.py +++ b/skfda/exploratory/visualization/boxplot.py @@ -210,14 +210,15 @@ class Boxplot(FDataBoxplot): """ - def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], + def __init__(self, fdatagrid, depth_method=modified_band_depth, prob=[0.5], factor=1.5): """Initialization of the Boxplot class. Args: fdatagrid (FDataGrid): Object containing the data. - method (:ref:`depth measure `, optional): Method - used to order the data. Defaults to :func:`modified band depth + depth_method (:ref:`depth measure `, optional): + Method used to order the data. Defaults to :func:`modified + band depth `. prob (list of float, optional): List with float numbers (in the range from 1 to 0) that indicate which central regions to @@ -241,7 +242,7 @@ def __init__(self, fdatagrid, method=modified_band_depth, prob=[0.5], self._envelopes = [None] * len(prob) - depth = method(fdatagrid) + depth = depth_method(fdatagrid) indices_descending_depth = (-depth).argsort(axis=0) # The median is the deepest curve diff --git a/skfda/exploratory/visualization/magnitude_shape_plot.py b/skfda/exploratory/visualization/magnitude_shape_plot.py index b4ef8a044..9cff65ec8 100644 --- a/skfda/exploratory/visualization/magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/magnitude_shape_plot.py @@ -16,7 +16,8 @@ import numpy as np from skfda.exploratory.depth import modified_band_depth -from ..outliers import directional_outlyingness_stats +from ..outliers import (directional_outlyingness_stats, + DirectionalOutlierDetector) __author__ = "Amanda Hernando Bernabé" @@ -35,57 +36,14 @@ class MagnitudeShapePlot: \mathbf{MO}\rVert`) is plotted in the x-axis, and the variation of the directional outlyingness (:math:`VO`) in the y-axis. - Considering :math:`\mathbf{Y} = \left(\mathbf{MO}^T, VO\right)^T`, the - outlier detection method is implemented as described below. - - First, the square robust Mahalanobis distance is calculated based on a - sample of size :math:`h \leq fdatagrid.nsamples`: - - .. math:: - {RMD}^2\left( \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right) = \left( - \mathbf{Y} - \mathbf{\tilde{Y}}^*_J\right)^T {\mathbf{S}^*_J}^{-1} - \left( \mathbf{Y} - \mathbf{\tilde{Y}}^*_J\right) - - where :math:`J` denotes the group of :math:`h` samples that minimizes the - determinant of the corresponding covariance matrix, - :math:`\mathbf{\tilde{Y}}^*_J = h^{-1}\sum_{i\in{J}}\mathbf{Y}_i` and - :math:`\mathbf{S}^*_J = h^{-1}\sum_{i\in{J}}\left( \mathbf{Y}_i - \mathbf{ - \tilde{Y}}^*_J\right) \left( \mathbf{Y}_i - \mathbf{\tilde{Y}}^*_J - \right)^T`. The sub-sample of size h controls the robustness of the method. - - Then, the tail of this distance distribution is approximated as follows: - - .. math:: - \frac{c\left(m - p\right)}{m\left(p + 1\right)}RMD^2\left( - \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right)\sim F_{p+1, m-p} - - where :math:`p` is the dmension of the image, and :math:`c` and :math:`m` - are parameters determining the degrees of freedom of the - :math:`F`-distribution and the scaling factor. - - .. math:: - c = E \left[s^*_{jj}\right] - - where :math:`s^*_{jj}` are the diagonal elements of MCD and - - .. math:: - m = \frac{2}{CV^2} - - where :math:`CV` is the estimated coefficient of variation of the diagonal - elements of the MCD shape estimator. - - Finally, we choose a cutoff value to determine the outliers, C , - as the :math:`\alpha` quantile of :math:`F_{p+1, m-p}`. We set - :math:`\alpha = 0.993`, which is used in the classical boxplot for - detecting outliers under a normal distribution. + The outliers are detected using an instance of + :class:`DirectionalOutlierDetector`. Attributes: fdatagrid (FDataGrid): Object to be visualized. depth_method (:ref:`depth measure `, optional): Method used to order the data. Defaults to :func:`modified band depth `. - dim_weights (array_like, optional): an array containing the weights - of each of the dimensions of the image. pointwise_weights (array_like, optional): an array containing the weights of each points of discretisation where values have been recorded. @@ -163,7 +121,7 @@ class MagnitudeShapePlot: [ 0. , 0. ], [-0.73333333, 0.36740741], [-1. , 0.53333333]]), - outliers=array([0, 0, 0, 0]), + outliers=array([False, False, False, False]), colormap=seismic, color=0.2, outliercol=(0.8,), @@ -172,9 +130,7 @@ class MagnitudeShapePlot: title='MS-Plot') """ - def __init__(self, fdatagrid, depth_method=modified_band_depth, - pointwise_weights=None, alpha=0.993, - assume_centered=False, support_fraction=None, random_state=0): + def __init__(self, fdatagrid, **kwargs): """Initialization of the MagnitudeShapePlot class. Args: @@ -212,47 +168,15 @@ def __init__(self, fdatagrid, depth_method=modified_band_depth, if fdatagrid.ndim_image > 1: raise NotImplementedError("Only support 1 dimension on the image.") - # The depths of the samples are calculated giving them an ordering. - *_, mean_dir_outl, variation_dir_outl = directional_outlyingness_stats( - fdatagrid, - depth_method, - pointwise_weights) - - points = np.array( - list( - zip( - mean_dir_outl.ravel(), variation_dir_outl - ) - )) - - # The square mahalanobis distances of the samples are - # calulated using MCD. - cov = MinCovDet(store_precision=False, assume_centered=assume_centered, - support_fraction=support_fraction, - random_state=random_state).fit(points) - rmd_2 = cov.mahalanobis(points) - - # Calculation of the degrees of freedom of the F-distribution - # (approximation of the tail of the distance distribution). - s_jj = np.diag(cov.covariance_) - c = np.mean(s_jj) - m = 2 / np.square(variation(s_jj)) - p = fdatagrid.ndim_image - dfn = p + 1 - dfd = m - p - - # Calculation of the cutoff value and scaling factor to identify - # outliers. - cutoff_value = f.ppf(alpha, dfn, dfd, loc=0, scale=1) - scaling = c * dfd / m / dfn - outliers = (scaling * rmd_2 > cutoff_value) * 1 + self.outlier_detector = DirectionalOutlierDetector(**kwargs) + + y = self.outlier_detector.fit_predict(fdatagrid) + + points = self.outlier_detector.points_ + + outliers = (y == -1) self._fdatagrid = fdatagrid - self._depth_method = depth_method - self._pointwise_weights = pointwise_weights - self._alpha = alpha - self._mean_dir_outl = mean_dir_outl - self._variation_dir_outl = variation_dir_outl self._points = points self._outliers = outliers self._colormap = plt.cm.get_cmap('seismic') @@ -268,19 +192,19 @@ def fdatagrid(self): @property def depth_method(self): - return self._depth_method + return self.outlier_detector.depth_method @property def pointwise_weights(self): - return self._pointwise_weights + return self.outlier_detector.pointwise_weights @property def alpha(self): - return self._alpha + return self.outlier_detector.alpha @property def points(self): - return self._points + return self.outlier_detector.points_ @property def outliers(self): @@ -340,7 +264,7 @@ def plot(self, ax=None): ax = matplotlib.pyplot.gca() colors_rgba = [tuple(i) for i in colors] - ax.scatter(self._mean_dir_outl.ravel(), self._variation_dir_outl, + ax.scatter(self.points[:, 0], self.points[:, 1], color=colors_rgba) ax.set_xlabel(self.xlabel) diff --git a/tests/test_fdata_boxplot.py b/tests/test_fdata_boxplot.py index 4aaac2eab..d9fd44f2a 100644 --- a/tests/test_fdata_boxplot.py +++ b/tests/test_fdata_boxplot.py @@ -16,7 +16,7 @@ def test_fdboxplot_univariate(self): [-0.5, -0.5, -0.5, -1, -1, -1]] sample_points = [0, 2, 4, 6, 8, 10] fd = FDataGrid(data_matrix, sample_points) - fdataBoxplot = Boxplot(fd, method=fraiman_muniz_depth) + fdataBoxplot = Boxplot(fd, depth_method=fraiman_muniz_depth) np.testing.assert_array_equal( fdataBoxplot.median.ravel(), np.array([-1., -1., -0.5, 1., 1., 0.5])) From 9147c30f859b48e04ac23c290bc5d9b3d7af2f9b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 22 Aug 2019 03:04:41 +0200 Subject: [PATCH 172/222] Corrected error in surface boxplot docstring. --- skfda/exploratory/visualization/boxplot.py | 27 +++++++++------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/boxplot.py index 6439c9a57..e3e183ab9 100644 --- a/skfda/exploratory/visualization/boxplot.py +++ b/skfda/exploratory/visualization/boxplot.py @@ -421,8 +421,9 @@ class SurfaceBoxplot(FDataBoxplot): data with domain dimension 2. Nevertheless, it does not implement the enhanced surface boxplot. - Based on the center outward ordering induced by a :ref:`depth measure - ` for functional data, it represents the envelope of the + Based on the center outward ordering induced by a + :ref:`depth measure ` + for functional data, it represents the envelope of the 50% central region, the median curve, and the maximum non-outlying envelope. @@ -504,19 +505,14 @@ class SurfaceBoxplot(FDataBoxplot): [[ 4. ], [ 0.4], [ 5. ]]]))) - outlying envelope=array([[[[ 1. , 0.3, 1. ], - [ 2. , 0.4, 2. ]], - - [[ 1. , 0.3, 1. ], - [ 2. , 0.4, 2. ]]], - - - [[[ 4. , 1.5, 3. ], - [ 8. , 2. , 9. ]], - - [[ 4. , 1.5, 3. ], - [ 8. , 2. , 9. ]]]])) + [ 2. , 0.4, 2. ]], + [[ 1. , 0.3, 1. ], + [ 2. , 0.4, 2. ]]], + [[[ 4. , 1.5, 3. ], + [ 8. , 2. , 9. ]], + [[ 4. , 1.5, 3. ], + [ 8. , 2. , 9. ]]]])) """ @@ -606,8 +602,7 @@ def outcol(self, value): self._outcol = value def plot(self, fig=None, ax=None, nrows=None, ncols=None): - """Visualization of the surface boxplot of the fdatagrid - (ndim_domain=2). + """Visualization of the surface boxplot of the fdatagrid (ndim_domain=2). Args: fig (figure object, optional): figure over with the graphs are From ef496dcc2412c8cf3c086c80effc5190520c2f1a Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 22 Aug 2019 03:17:13 +0200 Subject: [PATCH 173/222] Fixed link. --- docs/modules/exploratory/outliers.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/exploratory/outliers.rst b/docs/modules/exploratory/outliers.rst index 2b2a00087..290a1e377 100644 --- a/docs/modules/exploratory/outliers.rst +++ b/docs/modules/exploratory/outliers.rst @@ -8,7 +8,7 @@ categorize exactly as each outlier detection method looks at different features identify the outliers. Each of the outlier detection methods in scikit-fda has the same API as the outlier detection methods of -`scikit-learn `. +`scikit-learn `_. One of the most common ways of outlier detection is given by the functional data boxplot. An observation is marked as an outlier if it has points :math:`1.5 \cdot IQR` times outside the region containing the deepest 50% of the curves From 2d53eb67f1ae2fa2d791e1525eb06caa5520ad41 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 22 Aug 2019 13:29:06 +0200 Subject: [PATCH 174/222] Fixed surface boxplot test. --- skfda/exploratory/visualization/boxplot.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/boxplot.py index e3e183ab9..1e1f9f191 100644 --- a/skfda/exploratory/visualization/boxplot.py +++ b/skfda/exploratory/visualization/boxplot.py @@ -505,15 +505,6 @@ class SurfaceBoxplot(FDataBoxplot): [[ 4. ], [ 0.4], [ 5. ]]]))) - outlying envelope=array([[[[ 1. , 0.3, 1. ], - [ 2. , 0.4, 2. ]], - [[ 1. , 0.3, 1. ], - [ 2. , 0.4, 2. ]]], - [[[ 4. , 1.5, 3. ], - [ 8. , 2. , 9. ]], - [[ 4. , 1.5, 3. ], - [ 8. , 2. , 9. ]]]])) - """ From 3c77657bcb072d083049c58c46dcdac9c1255f96 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Thu, 22 Aug 2019 23:49:02 +0200 Subject: [PATCH 175/222] Fix setuptools warning The license file was renamed to ``LICENSE.txt``. Must change accordingly in the manifest. --- MANIFEST.in | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index 6f520378a..156bbd1f6 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,7 +1,6 @@ include README.rst include MANIFEST.in -include LICENSE include VERSION include pyproject.toml include *.txt -recursive-include deps * \ No newline at end of file +recursive-include deps * From 3d5b461cc817c25148c9a158f438aa468c732252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Fri, 23 Aug 2019 00:17:03 +0200 Subject: [PATCH 176/222] Update skfda/exploratory/outliers/_directional_outlyingness.py Co-Authored-By: Pablo Marcos --- skfda/exploratory/outliers/_directional_outlyingness.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 19ed7bd30..c20c90a49 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -236,7 +236,7 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): \frac{c\left(m - p\right)}{m\left(p + 1\right)}RMD^2\left( \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right)\sim F_{p+1, m-p} - where :math:`p` is the dmension of the image, and :math:`c` and :math:`m` + where :math:`p` is the dimension of the image, and :math:`c` and :math:`m` are parameters determining the degrees of freedom of the :math:`F`-distribution and the scaling factor. From 9611b3bf43092c02419c3daa341810fc6c0b88cc Mon Sep 17 00:00:00 2001 From: pablomm Date: Fri, 23 Aug 2019 12:16:56 +0200 Subject: [PATCH 177/222] Change in documentation lp to l2, and __doc__ in internal modules --- skfda/_neighbors/base.py | 8 ++++---- skfda/_neighbors/classification.py | 13 +++++++------ skfda/_neighbors/regression.py | 18 +++++++++--------- skfda/_neighbors/unsupervised.py | 5 +++-- 4 files changed, 23 insertions(+), 21 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index c83d938e0..953ae3f3b 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -1,4 +1,4 @@ - +"""Base classes for the neighbor estimators""" from abc import ABC, abstractmethod @@ -100,7 +100,7 @@ class NeighborsBase(ABC, BaseEstimator): @abstractmethod def __init__(self, n_neighbors=None, radius=None, weights='uniform', algorithm='auto', - leaf_size=30, metric='lp_distance', metric_params=None, + leaf_size=30, metric='l2', metric_params=None, n_jobs=None, sklearn_metric=False): self.n_neighbors = n_neighbors @@ -162,7 +162,7 @@ def fit(self, X, y=None): if not self.sklearn_metric: # Constructs sklearn metric to manage vector - if self.metric == 'lp_distance': + if self.metric == 'l2': metric = lp_distance else: metric = self.metric @@ -509,7 +509,7 @@ def fit(self, X, y): if not self.sklearn_metric: - if self.metric == 'lp_distance': + if self.metric == 'l2': metric = lp_distance else: metric = self.metric diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 3e41b1e1c..1034725cd 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -1,3 +1,4 @@ +"""Neighbor models for supervised classification.""" from .base import (NeighborsBase, NeighborsMixin, KNeighborsMixin, NeighborsClassifierMixin, RadiusNeighborsMixin) @@ -51,7 +52,7 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, metric : string or callable, (default :func:`lp_distance `) the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module + the L2 distance. See the documentation of the metrics module for a list of available metrics. metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. @@ -118,7 +119,7 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, """ def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric='lp_distance', metric_params=None, + leaf_size=30, metric='l2', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -206,7 +207,7 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, metric : string or callable, (default :func:`lp_distance `) the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module + the L2 distance. See the documentation of the metrics module for a list of available metrics. outlier_label : int, optional (default = None) Label, which is given for outlier samples (samples with no @@ -266,7 +267,7 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, """ def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric='lp_distance', metric_params=None, + leaf_size=30, metric='l2', metric_params=None, outlier_label=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -356,7 +357,7 @@ class and return a :class:`FData` object with only one sample """ - def __init__(self, metric='lp_distance', mean='mean'): + def __init__(self, metric='l2', mean='mean'): """Initialize the classifier.""" self.metric = metric self.mean = mean @@ -374,7 +375,7 @@ def fit(self, X, y): """ if self.metric == 'precomputed': raise ValueError("Precomputed is not supported.") - elif self.metric == 'lp_distance': + elif self.metric == 'l2': self._pairwise_distance = pairwise_distance(lp_distance) else: self._pairwise_distance = pairwise_distance(self.metric) diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index 85648725b..af16b44d2 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -1,4 +1,4 @@ - +"""Neighbor models for regression.""" from sklearn.neighbors import KNeighborsRegressor as _KNeighborsRegressor from sklearn.neighbors import (RadiusNeighborsRegressor as @@ -52,7 +52,7 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, metric : string or callable, (default :func:`lp_distance `) the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module + the L2 distance. See the documentation of the metrics module for a list of available metrics. metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. @@ -113,7 +113,7 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, """ def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric='lp_distance', metric_params=None, + leaf_size=30, metric='l2', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -184,7 +184,7 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, metric : string or callable, (default :func:`lp_distance `) the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module + the L2 distance. See the documentation of the metrics module for a list of available metrics. metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. @@ -239,7 +239,7 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, """ def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric='lp_distance', metric_params=None, + leaf_size=30, metric='l2', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -314,7 +314,7 @@ class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, metric : string or callable, (default :func:`lp_distance `) the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module + the L2 distance. See the documentation of the metrics module for a list of available metrics. metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. @@ -369,7 +369,7 @@ class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, """ def __init__(self, n_neighbors=5, weights='uniform', regressor='mean', - algorithm='auto', leaf_size=30, metric='lp_distance', + algorithm='auto', leaf_size=30, metric='l2', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" @@ -433,7 +433,7 @@ class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, metric : string or callable, (default :func:`lp_distance `) the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module + the L2 distance. See the documentation of the metrics module for a list of available metrics. metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. @@ -490,7 +490,7 @@ class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, """ def __init__(self, radius=1., weights='uniform', regressor='mean', - algorithm='auto', leaf_size=30, metric='lp_distance', + algorithm='auto', leaf_size=30, metric='l2', metric_params=None, outlier_response=None, n_jobs=1, sklearn_metric=False): """Initialize the classifier.""" diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index 4ecce40cb..1a103cb74 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -1,3 +1,4 @@ +"""Unsupervised learner for implementing neighbor searches.""" from .base import (NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, KNeighborsMixin, RadiusNeighborsMixin, _to_sklearn_metric) @@ -30,7 +31,7 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, metric : string or callable, (default :func:`lp_distance `) the distance metric to use for the tree. The default metric is - the Lp distance. See the documentation of the metrics module + the L2 distance. See the documentation of the metrics module for a list of available metrics. metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. @@ -99,7 +100,7 @@ class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, """ def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', - leaf_size=30, metric='lp_distance', metric_params=None, + leaf_size=30, metric='l2', metric_params=None, n_jobs=1, sklearn_metric=False): """Initialize the nearest neighbors searcher.""" From 8372304a07073d29da6dcb0895f29db076e36180 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 23 Aug 2019 16:08:59 +0200 Subject: [PATCH 178/222] Add references and example of MS-plot. --- examples/plot_magnitude_shape_synthetic.py | 120 ++++++++++++++++++ .../outliers/_directional_outlyingness.py | 20 +-- 2 files changed, 132 insertions(+), 8 deletions(-) create mode 100644 examples/plot_magnitude_shape_synthetic.py diff --git a/examples/plot_magnitude_shape_synthetic.py b/examples/plot_magnitude_shape_synthetic.py new file mode 100644 index 000000000..54c5d2f67 --- /dev/null +++ b/examples/plot_magnitude_shape_synthetic.py @@ -0,0 +1,120 @@ +""" +Magnitude-Shape Plot synthetic example +====================================== + +Shows the use of the MS-Plot applied to a synthetic dataset. +""" + +# Author: Carlos Ramos Carreño +# License: MIT + +# sphinx_gallery_thumbnail_number = 3 + +import matplotlib.pyplot as plt +import numpy as np +import skfda +from skfda.exploratory.visualization.magnitude_shape_plot import ( + MagnitudeShapePlot) + + +############################################################################## +# First, we generate a synthetic dataset following [DaWe18]_ + +random_state = np.random.RandomState(0) +n_samples = 95 + +fd = skfda.datasets.make_gaussian_process( + n_samples=n_samples, n_features=100, + cov=skfda.misc.covariances.Exponential(), + mean=lambda t: 4 * t, + random_state=random_state) + +############################################################################## +# We now add the outliers + +mangnitude_outlier = skfda.datasets.make_gaussian_process( + n_samples=1, n_features=100, + cov=skfda.misc.covariances.Exponential(), + mean=lambda t: 4 * t + 20, + random_state=random_state) + +shape_outlier_shift = skfda.datasets.make_gaussian_process( + n_samples=1, n_features=100, + cov=skfda.misc.covariances.Exponential(), + mean=lambda t: 4 * t + 10 * (t > 0.4), + random_state=random_state) + +shape_outlier_peak = skfda.datasets.make_gaussian_process( + n_samples=1, n_features=100, + cov=skfda.misc.covariances.Exponential(), + mean=lambda t: 4 * t - 10 * ((0.25 < t) & (t < 0.3)), + random_state=random_state) + +shape_outlier_sin = skfda.datasets.make_gaussian_process( + n_samples=1, n_features=100, + cov=skfda.misc.covariances.Exponential(), + mean=lambda t: 4 * t + 2 * np.sin(18 * t), + random_state=random_state) + +shape_outlier_slope = skfda.datasets.make_gaussian_process( + n_samples=1, n_features=100, + cov=skfda.misc.covariances.Exponential(), + mean=lambda t: 7 * t, + random_state=random_state) + +magnitude_shape_outlier = skfda.datasets.make_gaussian_process( + n_samples=1, n_features=100, + cov=skfda.misc.covariances.Exponential(), + mean=lambda t: 4 * t + 2 * np.sin(18 * t) - 20, + random_state=random_state) + + +fd = fd.concatenate(mangnitude_outlier, shape_outlier_shift, + shape_outlier_peak, shape_outlier_sin, + shape_outlier_slope, magnitude_shape_outlier) + +############################################################################## +# The data is plotted to show the curves we are working with. +labels = [0] * n_samples + [1] * 6 + +plt.figure() +fd.plot(sample_labels=labels, + label_colors=['lightgrey', 'black']) + +############################################################################## +# The MS-Plot is generated. In order to show the results, the +# :func:`plot method ` +# is used. + +msplot = MagnitudeShapePlot(fdatagrid=fd) + +plt.figure() +msplot.plot() + +############################################################################## +# To show the utility of the plot, the curves are plotted showing each outlier +# in a different color + +labels = [0] * n_samples + [1, 2, 3, 4, 5, 6] +colors = ['lightgrey', 'orange', 'blue', 'black', + 'green', 'brown', 'lightblue'] + +plt.figure() +fd.plot(sample_labels=labels, + label_colors=colors) + +############################################################################## +# We now show the points in the MS-plot using the same colors + +plt.figure() +plt.scatter(msplot.points[:, 0], msplot.points[:, 1], + c=colors[0:1] * n_samples + colors[1:]) +plt.title("MS-Plot") +plt.xlabel("magnitude outlyingness") +plt.ylabel("shape outlyingness") + +############################################################################## +# .. rubric:: References +# .. [DaWe18] Dai, Wenlin, and Genton, Marc G. "Multivariate functional data +# visualization and outlier detection." Journal of Computational and +# Graphical Statistics 27.4 (2018): 923-934. diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index c20c90a49..d22130292 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -141,6 +141,11 @@ def directional_outlyingness_stats( >>> stats.variation_directional_outlyingness array([ 0.74074074, 0.08230453, 0.0462963 , 0.06687243]) + References: + Dai, Wenlin, and Genton, Marc G. "Directional outlyingness for + multivariate functional data." Computational Statistics & Data + Analysis 131 (2019): 50-65. + """ if fdatagrid.ndim_domain > 1: raise NotImplementedError("Only support 1 dimension on the domain.") @@ -299,6 +304,11 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): >>> out_detector.fit_predict(fd) array([1, 1, 1, 1]) + References: + Dai, Wenlin, and Genton, Marc G. "Multivariate functional data + visualization and outlier detection." Journal of Computational + and Graphical Statistics 27.4 (2018): 923-934. + """ def __init__(self, *, depth_method=modified_band_depth, @@ -330,7 +340,7 @@ def _compute_points(self, X): return points - def fit(self, X, y=None): + def fit_predict(self, X, y=None): self.points_ = self._compute_points(X) @@ -355,13 +365,7 @@ def fit(self, X, y=None): self.cutoff_value_ = f.ppf(self.alpha, dfn, dfd, loc=0, scale=1) self.scaling_ = c * dfd / m / dfn - return self - - def predict(self, X): - - points = self._compute_points(X) - - rmd_2 = self.cov_.mahalanobis(points) + rmd_2 = self.cov_.mahalanobis(self.points_) outliers = self.scaling_ * rmd_2 > self.cutoff_value_ From d3f2277f1e5a6a186c6edf21ca515bb8945cab0b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 26 Aug 2019 18:29:12 +0200 Subject: [PATCH 179/222] Fixed ms plot. --- examples/plot_magnitude_shape.py | 5 +- examples/plot_magnitude_shape_synthetic.py | 10 +- skfda/exploratory/depth/__init__.py | 4 + .../exploratory/{depth.py => depth/_depth.py} | 156 +++++++++++------- skfda/exploratory/depth/multivariate.py | 33 ++++ .../outliers/_directional_outlyingness.py | 29 ++-- .../visualization/magnitude_shape_plot.py | 12 +- tests/test_magnitude_shape.py | 4 +- 8 files changed, 166 insertions(+), 87 deletions(-) create mode 100644 skfda/exploratory/depth/__init__.py rename skfda/exploratory/{depth.py => depth/_depth.py} (68%) create mode 100644 skfda/exploratory/depth/multivariate.py diff --git a/examples/plot_magnitude_shape.py b/examples/plot_magnitude_shape.py index 2749abd7f..b12e06770 100644 --- a/examples/plot_magnitude_shape.py +++ b/examples/plot_magnitude_shape.py @@ -13,7 +13,7 @@ import matplotlib.pyplot as plt import numpy as np from skfda import datasets -from skfda.exploratory.depth import fraiman_muniz_depth +from skfda.exploratory.depth import fraiman_muniz_depth, modified_band_depth from skfda.exploratory.visualization.magnitude_shape_plot import ( MagnitudeShapePlot) @@ -50,7 +50,8 @@ # between outliers or not. In particular the tones of the default colormap, # (which is 'seismic' and can be customized), are assigned. -msplot = MagnitudeShapePlot(fdatagrid=fd_temperatures) +msplot = MagnitudeShapePlot(fdatagrid=fd_temperatures, + depth_method=modified_band_depth) color = 0.3 outliercol = 0.7 diff --git a/examples/plot_magnitude_shape_synthetic.py b/examples/plot_magnitude_shape_synthetic.py index 54c5d2f67..4adc1a0fe 100644 --- a/examples/plot_magnitude_shape_synthetic.py +++ b/examples/plot_magnitude_shape_synthetic.py @@ -21,7 +21,7 @@ # First, we generate a synthetic dataset following [DaWe18]_ random_state = np.random.RandomState(0) -n_samples = 95 +n_samples = 200 fd = skfda.datasets.make_gaussian_process( n_samples=n_samples, n_features=100, @@ -32,7 +32,7 @@ ############################################################################## # We now add the outliers -mangnitude_outlier = skfda.datasets.make_gaussian_process( +magnitude_outlier = skfda.datasets.make_gaussian_process( n_samples=1, n_features=100, cov=skfda.misc.covariances.Exponential(), mean=lambda t: 4 * t + 20, @@ -59,7 +59,7 @@ shape_outlier_slope = skfda.datasets.make_gaussian_process( n_samples=1, n_features=100, cov=skfda.misc.covariances.Exponential(), - mean=lambda t: 7 * t, + mean=lambda t: 10 * t, random_state=random_state) magnitude_shape_outlier = skfda.datasets.make_gaussian_process( @@ -69,7 +69,7 @@ random_state=random_state) -fd = fd.concatenate(mangnitude_outlier, shape_outlier_shift, +fd = fd.concatenate(magnitude_outlier, shape_outlier_shift, shape_outlier_peak, shape_outlier_sin, shape_outlier_slope, magnitude_shape_outlier) @@ -107,7 +107,7 @@ # We now show the points in the MS-plot using the same colors plt.figure() -plt.scatter(msplot.points[:, 0], msplot.points[:, 1], +plt.scatter(msplot.points[:, 0].ravel(), msplot.points[:, 1].ravel(), c=colors[0:1] * n_samples + colors[1:]) plt.title("MS-Plot") plt.xlabel("magnitude outlyingness") diff --git a/skfda/exploratory/depth/__init__.py b/skfda/exploratory/depth/__init__.py new file mode 100644 index 000000000..931a0c837 --- /dev/null +++ b/skfda/exploratory/depth/__init__.py @@ -0,0 +1,4 @@ +from ._depth import (band_depth, + modified_band_depth, + fraiman_muniz_depth, + outlyingness_to_depth) diff --git a/skfda/exploratory/depth.py b/skfda/exploratory/depth/_depth.py similarity index 68% rename from skfda/exploratory/depth.py rename to skfda/exploratory/depth/_depth.py index d8cff006d..3ce990acc 100644 --- a/skfda/exploratory/depth.py +++ b/skfda/exploratory/depth/_depth.py @@ -4,20 +4,57 @@ from the center (larger values) outwards(smaller ones).""" from functools import reduce -import itertools +import math import scipy.integrate from scipy.stats import rankdata import numpy as np -from .. import FDataGrid - __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" +def outlyingness_to_depth(outlyingness, *, supreme=None): + r"""Convert outlyingness function to depth function. + + An outlyingness function :math:`O(x)` can be converted to a depth + function as + + .. math:: + D(x) = \frac{1}{1 + O(x)} + + if :math:`O(x)` is unbounded or as + + .. math:: + D(x) = 1 - \frac{O(x)}{\sup O(x)} + + if :math:`O(x)` is bounded ([Se06]_). + + Args: + outlyingness (Callable): Outlyingness function. + supreme (float, optional): Supreme value of the outlyingness function. + + Returns: + Callable: The corresponding depth function. + + References: + .. [Se06] Serfling, R. (2006). Depth functions in nonparametric + multivariate inference. DIMACS Series in Discrete Mathematics and + Theoretical Computer Science, 72, 1. + """ + + if supreme is None or math.isinf(supreme): + def depth(*args, **kwargs): + return 1 / (1 + outlyingness(*args, **kwargs)) + else: + def depth(*args, **kwargs): + return 1 - outlyingness(*args, **kwargs) / supreme + + return depth + + def _rank_samples(fdatagrid): """Ranks the he samples in the FDataGrid at each point of discretisation. @@ -30,12 +67,14 @@ def _rank_samples(fdatagrid): Examples: Univariate setting: + >>> import skfda + >>> >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], ... [-1, -1, -0.5, 1, 1, 0.5], ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] - >>> fd = FDataGrid(data_matrix, sample_points) + >>> fd = skfda.FDataGrid(data_matrix, sample_points) >>> _rank_samples(fd) array([[ 4., 4., 4., 4., 4., 4.], [ 3., 3., 3., 3., 3., 3.], @@ -49,7 +88,7 @@ def _rank_samples(fdatagrid): ... [[[2], [0.5], [2]], ... [[3], [0.6], [3]]]] >>> sample_points = [[2, 4], [3, 6, 8]] - >>> fd = FDataGrid(data_matrix, sample_points) + >>> fd = skfda.FDataGrid(data_matrix, sample_points) >>> _rank_samples(fd) array([[[ 1., 2., 1.], [ 2., 1., 2.]], @@ -70,7 +109,7 @@ def _rank_samples(fdatagrid): return ranks -def band_depth(fdatagrid, pointwise=False): +def band_depth(fdatagrid, *, pointwise=False): """Implementation of Band Depth for functional data. The band depth of each sample is obtained by computing the fraction of the @@ -82,47 +121,45 @@ def band_depth(fdatagrid, pointwise=False): Args: fdatagrid (FDataGrid): Object over whose samples the band depth is going to be calculated. - pointwise (boolean, optional): Indicates if the pointwise depth is also - returned. Defaults to False. - - Returns: - depth (numpy.darray): Array containing the band depth of the samples. + pointwise (boolean, optional): Indicates if the pointwise depth is + returned instead. Defaults to False. Returns: - depth_pointwise (numpy.darray, optional): Array containing the band - depth of the samples at each point of discretisation. Only returned + depth (numpy.darray): Array containing the band depth of the samples, + or the band depth of the samples at each point of discretization if pointwise equals to True. Examples: + >>> import skfda + >>> >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], ... [-1, -1, -0.5, 1, 1, 0.5], ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] - >>> fd = FDataGrid(data_matrix, sample_points) + >>> fd = skfda.FDataGrid(data_matrix, sample_points) >>> band_depth(fd) array([ 0.5 , 0.83333333, 0.5 , 0.5 ]) """ - n = fdatagrid.nsamples - nchoose2 = n * (n - 1) / 2 - - ranks = _rank_samples(fdatagrid) - axis = tuple(range(1, fdatagrid.ndim_domain + 1)) - nsamples_above = fdatagrid.nsamples - np.amax(ranks, axis=axis) - nsamples_below = np.amin(ranks, axis=axis) - 1 - depth = ((nsamples_below * nsamples_above + fdatagrid.nsamples - 1) / - nchoose2) - if pointwise: - _, depth_pointwise = modified_band_depth(fdatagrid, pointwise) - return depth, depth_pointwise + return modified_band_depth(fdatagrid, pointwise) else: + n = fdatagrid.nsamples + nchoose2 = n * (n - 1) / 2 + + ranks = _rank_samples(fdatagrid) + axis = tuple(range(1, fdatagrid.ndim_domain + 1)) + nsamples_above = fdatagrid.nsamples - np.amax(ranks, axis=axis) + nsamples_below = np.amin(ranks, axis=axis) - 1 + depth = ((nsamples_below * nsamples_above + fdatagrid.nsamples - 1) / + nchoose2) + return depth -def modified_band_depth(fdatagrid, pointwise=False): +def modified_band_depth(fdatagrid, *, pointwise=False): """Implementation of Modified Band Depth for functional data. The band depth of each sample is obtained by computing the fraction of time @@ -135,28 +172,27 @@ def modified_band_depth(fdatagrid, pointwise=False): fdatagrid (FDataGrid): Object over whose samples the modified band depth is going to be calculated. pointwise (boolean, optional): Indicates if the pointwise depth is - also returned. Defaults to False. + returned instead. Defaults to False. Returns: depth (numpy.darray): Array containing the modified band depth of the - samples. - - Returns: - depth_pointwise (numpy.darray, optional): Array containing the modified - band depth of the samples at each point of discretisation. Only - returned if pointwise equals to True. + samples, or the modified band depth of the samples at each point + of discretization if pointwise equals to True. Examples: + >>> import skfda + >>> >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], ... [-1, -1, -0.5, 1, 1, 0.5], ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] - >>> fd = FDataGrid(data_matrix, sample_points) - >>> depth, pointwise = modified_band_depth(fd, pointwise = True) + >>> fd = skfda.FDataGrid(data_matrix, sample_points) + >>> depth = modified_band_depth(fd) >>> depth.round(2) array([ 0.5 , 0.83, 0.72, 0.67]) + >>> pointwise = modified_band_depth(fd, pointwise = True) >>> pointwise.round(2) array([[ 0.5 , 0.5 , 0.5 , 0.5 , 0.5 , 0.5 ], [ 0.83, 0.83, 0.83, 0.83, 0.83, 0.83], @@ -172,15 +208,17 @@ def modified_band_depth(fdatagrid, pointwise=False): nsamples_below = ranks - 1 match = nsamples_above * nsamples_below axis = tuple(range(1, fdatagrid.ndim_domain + 1)) - npoints_sample = reduce(lambda x, y: x * len(y), - fdatagrid.sample_points, 1) - proportion = match.sum(axis=axis) / npoints_sample - depth = (proportion + fdatagrid.nsamples - 1) / nchoose2 if pointwise: depth_pointwise = (match + fdatagrid.nsamples - 1) / nchoose2 - return depth, depth_pointwise + + return depth_pointwise else: + npoints_sample = reduce(lambda x, y: x * len(y), + fdatagrid.sample_points, 1) + proportion = match.sum(axis=axis) / npoints_sample + depth = (proportion + fdatagrid.nsamples - 1) / nchoose2 + return depth @@ -209,7 +247,7 @@ def _cumulative_distribution(column): return count_cumulative[indexes].reshape(column.shape) -def fraiman_muniz_depth(fdatagrid, pointwise=False): +def fraiman_muniz_depth(fdatagrid, *, pointwise=False): r"""Implementation of Fraiman and Muniz (FM) Depth for functional data. Each column is considered as the samples of an aleatory variable. @@ -229,32 +267,33 @@ def fraiman_muniz_depth(fdatagrid, pointwise=False): Args: fdatagrid (FDataGrid): Object over whose samples the FM depth is going to be calculated. - pointwise (boolean, optional): Indicates if the pointwise depth is also - returned. Defaults to False. + pointwise (boolean, optional): Indicates if the pointwise depth is + returned instead. Defaults to False. Returns: - depth (numpy.darray): Array containing the FM depth of the samples. - depth_pointwise (numpy.darray, optional): Array containing the FM depth - of the samples at each point of discretisation. Only returned if - pointwise equals to True. + depth (numpy.darray): Array containing the Fraiman-Muniz depth of the + samples, or the Fraiman-Muniz of the samples at each point + of discretization if pointwise equals to True. Examples: Currently, this depth function can only be used for univariate functional data: + >>> import skfda + >>> >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], ... [-1, -1, -0.5, 1, 1, 0.5], ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] - >>> fd = FDataGrid(data_matrix, sample_points) + >>> fd = skfda.FDataGrid(data_matrix, sample_points) >>> fraiman_muniz_depth(fd) array([ 0.5 , 0.75 , 0.925, 0.875]) You can use ``pointwise`` to obtain the pointwise depth, before the integral is applied. - >>> depth, pointwise = fraiman_muniz_depth(fd, pointwise = True) + >>> pointwise = fraiman_muniz_depth(fd, pointwise = True) >>> pointwise array([[ 0.5 , 0.5 , 0.5 , 0.5 , 0.5 , 0.5 ], [ 0.75, 0.75, 0.75, 0.75, 0.75, 0.75], @@ -271,14 +310,15 @@ def fraiman_muniz_depth(fdatagrid, pointwise=False): fdatagrid.data_matrix[:, i, 0]) ) for i in range(len(fdatagrid.sample_points[0]))]).T - interval_len = (fdatagrid.domain_range[0][1] - - fdatagrid.domain_range[0][0]) - - depth = (scipy.integrate.simps(pointwise_depth, - fdatagrid.sample_points[0]) - / interval_len) - if pointwise: - return depth, pointwise_depth + return pointwise_depth else: + + interval_len = (fdatagrid.domain_range[0][1] + - fdatagrid.domain_range[0][0]) + + depth = (scipy.integrate.simps(pointwise_depth, + fdatagrid.sample_points[0]) + / interval_len) + return depth diff --git a/skfda/exploratory/depth/multivariate.py b/skfda/exploratory/depth/multivariate.py new file mode 100644 index 000000000..0592e3d67 --- /dev/null +++ b/skfda/exploratory/depth/multivariate.py @@ -0,0 +1,33 @@ +import scipy.stats + +import numpy as np + +from . import outlyingness_to_depth + + +def _stagel_donoho_outlyingness(X, *, pointwise=False): + + if pointwise is False: + raise NotImplementedError("Only implemented pointwise") + + if X.ndim_codomain == 1: + # Special case, can be computed exactly + m = X.data_matrix[..., 0] + + return (np.abs(m - np.median(m, axis=0)) / + scipy.stats.median_absolute_deviation(m, axis=0)) + + else: + raise NotImplementedError("Only implemented for one dimension") + + +def projection_depth(X, *, pointwise=False): + """Returns the projection depth. + + The projection depth is the depth function associated with the + Stagel-Donoho outlyingness. + """ + + depth = outlyingness_to_depth(_stagel_donoho_outlyingness) + + return depth(X, pointwise=pointwise) diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index d22130292..7b825d844 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -3,14 +3,14 @@ from numpy import linalg as la import scipy.integrate from scipy.stats import f, variation +import scipy.stats from sklearn.base import BaseEstimator, OutlierMixin from sklearn.covariance import MinCovDet import numpy as np -from skfda.exploratory.depth import modified_band_depth +from skfda.exploratory.depth.multivariate import projection_depth from ... import FDataGrid -from ..depth import fraiman_muniz_depth class DirectionalOutlyingnessStats(typing.NamedTuple): @@ -21,8 +21,8 @@ class DirectionalOutlyingnessStats(typing.NamedTuple): def directional_outlyingness_stats( - fdatagrid: FDataGrid, - depth_method=fraiman_muniz_depth, + fdatagrid: FDataGrid, *, + depth_method=projection_depth, pointwise_weights=None) -> DirectionalOutlyingnessStats: r"""Computes the directional outlyingness of the functional data. @@ -155,13 +155,14 @@ def directional_outlyingness_stats( pointwise_weights.sum() != 1)): raise ValueError( "There must be a weight in pointwise_weights for each recorded " - "time point and altogether must sum 1.") + "time point and altogether must integrate to 1.") if pointwise_weights is None: pointwise_weights = np.ones( - len(fdatagrid.sample_points[0])) / len(fdatagrid.sample_points[0]) + len(fdatagrid.sample_points[0])) / ( + fdatagrid.domain_range[0][1] - fdatagrid.domain_range[0][0]) - _, depth_pointwise = depth_method(fdatagrid, pointwise=True) + depth_pointwise = depth_method(fdatagrid, pointwise=True) assert depth_pointwise.shape == fdatagrid.data_matrix.shape[:-1] # Obtaining the pointwise median sample Z, to calculate @@ -182,7 +183,6 @@ def directional_outlyingness_stats( # Calculation directinal outlyingness dir_outlyingness = (1 / depth_pointwise[..., np.newaxis] - 1) * v_unitary - assert dir_outlyingness.shape == fdatagrid.data_matrix.shape # Calculation mean directional outlyingness weighted_dir_outlyingness = (dir_outlyingness @@ -200,7 +200,8 @@ def directional_outlyingness_stats( mean_dir_outlyingness[:, np.newaxis, :], axis=-1)) weighted_norm = norm * pointwise_weights variation_dir_outlyingness = scipy.integrate.simps( - weighted_norm, fdatagrid.sample_points[0], axis=1) + weighted_norm, fdatagrid.sample_points[0], + axis=1) assert variation_dir_outlyingness.shape == (fdatagrid.nsamples,) functional_dir_outlyingness = (np.square(la.norm(mean_dir_outlyingness)) @@ -263,8 +264,8 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): Parameters: depth_method (:ref:`depth measure `, optional): - Method used to order the data. Defaults to :func:`modified band - depth `. + Method used to order the data. Defaults to :func:`projection + depth `. pointwise_weights (array_like, optional): an array containing the weights of each points of discretisati on where values have been recorded. @@ -311,7 +312,7 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): """ - def __init__(self, *, depth_method=modified_band_depth, + def __init__(self, *, depth_method=projection_depth, pointwise_weights=None, assume_centered=False, support_fraction=None, @@ -328,8 +329,8 @@ def _compute_points(self, X): # The depths of the samples are calculated giving them an ordering. *_, mean_dir_outl, variation_dir_outl = directional_outlyingness_stats( X, - self.depth_method, - self.pointwise_weights) + depth_method=self.depth_method, + pointwise_weights=self.pointwise_weights) points = np.array( list( diff --git a/skfda/exploratory/visualization/magnitude_shape_plot.py b/skfda/exploratory/visualization/magnitude_shape_plot.py index 9cff65ec8..6007ee362 100644 --- a/skfda/exploratory/visualization/magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/magnitude_shape_plot.py @@ -70,13 +70,14 @@ class MagnitudeShapePlot: Example: >>> import skfda + >>> from skfda.exploratory.depth import modified_band_depth >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], ... [-1, -1, -0.5, 1, 1, 0.5], ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = skfda.FDataGrid(data_matrix, sample_points) - >>> MagnitudeShapePlot(fd) + >>> MagnitudeShapePlot(fd, depth_method=modified_band_depth) MagnitudeShapePlot( FDataGrid=FDataGrid( array([[[ 1. ], @@ -136,8 +137,8 @@ def __init__(self, fdatagrid, **kwargs): Args: fdatagrid (FDataGrid): Object containing the data. depth_method (:ref:`depth measure `, optional): - Method used to order the data. Defaults to :func:`modified band - depth `. + Method used to order the data. Defaults to :func:`projection + depth `. pointwise_weights (array_like, optional): an array containing the weights of each points of discretisati on where values have been recorded. @@ -172,12 +173,9 @@ def __init__(self, fdatagrid, **kwargs): y = self.outlier_detector.fit_predict(fdatagrid) - points = self.outlier_detector.points_ - outliers = (y == -1) self._fdatagrid = fdatagrid - self._points = points self._outliers = outliers self._colormap = plt.cm.get_cmap('seismic') self._color = 0.2 @@ -264,7 +262,7 @@ def plot(self, ax=None): ax = matplotlib.pyplot.gca() colors_rgba = [tuple(i) for i in colors] - ax.scatter(self.points[:, 0], self.points[:, 1], + ax.scatter(self.points[:, 0].ravel(), self.points[:, 1].ravel(), color=colors_rgba) ax.set_xlabel(self.xlabel) diff --git a/tests/test_magnitude_shape.py b/tests/test_magnitude_shape.py index a864b3c83..af84aa175 100644 --- a/tests/test_magnitude_shape.py +++ b/tests/test_magnitude_shape.py @@ -3,6 +3,7 @@ import numpy as np from skfda import FDataGrid from skfda.datasets import fetch_weather +from skfda.exploratory.depth import modified_band_depth from skfda.exploratory.visualization.magnitude_shape_plot import ( MagnitudeShapePlot) @@ -15,7 +16,8 @@ def test_magnitude_shape_plot(self): sample_points=fd.sample_points, dataset_label=fd.dataset_label, axes_labels=fd.axes_labels[0:2]) - msplot = MagnitudeShapePlot(fd_temperatures, random_state=0) + msplot = MagnitudeShapePlot( + fd_temperatures, depth_method=modified_band_depth) np.testing.assert_allclose(msplot.points, np.array([[0.25839562, 3.14995827], [1.3774155, 0.91556716], From 663ec0eb56d376b31a9acd4cc9b080331ce1f5df Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 27 Aug 2019 14:01:21 +0200 Subject: [PATCH 180/222] Fixed tests --- .../outliers/_directional_outlyingness.py | 52 +++++++------- .../visualization/magnitude_shape_plot.py | 15 ++-- tests/test_magnitude_shape.py | 71 ++++++++++--------- tests/test_outliers.py | 26 +++---- 4 files changed, 82 insertions(+), 82 deletions(-) diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 7b825d844..27d0fb1a4 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -104,42 +104,42 @@ def directional_outlyingness_stats( >>> fd = FDataGrid(data_matrix, sample_points) >>> stats = directional_outlyingness_stats(fd) >>> stats.directional_outlyingness - array([[[ 1. ], - [ 1. ], - [ 1. ], - [ 1. ], - [ 1. ], - [ 1. ]], - [[ 0.33333333], - [ 0.33333333], - [ 0.33333333], - [ 0.33333333], - [ 0.33333333], - [ 0.33333333]], - [[-0.33333333], - [-0.33333333], + array([[[ 0.89932101], + [ 0.89932101], + [ 1.57381177], + [ 1.01173614], + [ 1.12415127], + [ 1.12415127]], + [[ 0. ], [ 0. ], [ 0. ], [ 0. ], - [ 0. ]], - [[ 0. ], [ 0. ], - [ 0. ], - [-0.33333333], - [-0.33333333], - [-0.33333333]]]) + [ 0. ]], + [[-0.89932101], + [-0.89932101], + [-0.67449076], + [-0.33724538], + [-0.22483025], + [-0.22483025]], + [[-0.44966051], + [-0.44966051], + [-0.67449076], + [-1.6862269 ], + [-2.02347228], + [-1.57381177]]]) >>> stats.functional_directional_outlyingness - array([ 3.93209877, 3.27366255, 3.23765432, 3.25823045]) + array([ 2.99742218, 2.93929124, 3.01966359, 3.36873005]) >>> stats.mean_directional_outlyingness - array([[ 1.66666667], - [ 0.55555556], - [-0.16666667], - [-0.27777778]]) + array([[ 1.12415127], + [ 0. ], + [-0.53959261], + [-1.17661166]]) >>> stats.variation_directional_outlyingness - array([ 0.74074074, 0.08230453, 0.0462963 , 0.06687243]) + array([ 0.05813094, 0. , 0.08037234, 0.4294388 ]) References: Dai, Wenlin, and Genton, Marc G. "Directional outlyingness for diff --git a/skfda/exploratory/visualization/magnitude_shape_plot.py b/skfda/exploratory/visualization/magnitude_shape_plot.py index 6007ee362..38b0acc27 100644 --- a/skfda/exploratory/visualization/magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/magnitude_shape_plot.py @@ -77,7 +77,7 @@ class MagnitudeShapePlot: ... [-0.5, -0.5, -0.5, -1, -1, -1]] >>> sample_points = [0, 2, 4, 6, 8, 10] >>> fd = skfda.FDataGrid(data_matrix, sample_points) - >>> MagnitudeShapePlot(fd, depth_method=modified_band_depth) + >>> MagnitudeShapePlot(fd) MagnitudeShapePlot( FDataGrid=FDataGrid( array([[[ 1. ], @@ -86,21 +86,18 @@ class MagnitudeShapePlot: [ 3. ], [ 2.5], [ 2. ]], - [[ 0.5], [ 0.5], [ 1. ], [ 2. ], [ 1.5], [ 1. ]], - [[-1. ], [-1. ], [-0.5], [ 1. ], [ 1. ], [ 0.5]], - [[-0.5], [-0.5], [-0.5], @@ -115,13 +112,13 @@ class MagnitudeShapePlot: interpolator=SplineInterpolator(interpolation_order=1, smoothness_parameter=0.0, monotone=False), keepdims=False), - depth_method=modified_band_depth, + depth_method=projection_depth, pointwise_weights=None, alpha=0.993, - points=array([[ 1.66666667, 0.74074074], - [ 0. , 0. ], - [-0.73333333, 0.36740741], - [-1. , 0.53333333]]), + points=array([[ 1.12415127, 0.05813094], + [ 0. , 0. ], + [-0.53959261, 0.08037234], + [-1.17661166, 0.4294388 ]]), outliers=array([False, False, False, False]), colormap=seismic, color=0.2, diff --git a/tests/test_magnitude_shape.py b/tests/test_magnitude_shape.py index af84aa175..8118fd234 100644 --- a/tests/test_magnitude_shape.py +++ b/tests/test_magnitude_shape.py @@ -19,41 +19,42 @@ def test_magnitude_shape_plot(self): msplot = MagnitudeShapePlot( fd_temperatures, depth_method=modified_band_depth) np.testing.assert_allclose(msplot.points, - np.array([[0.25839562, 3.14995827], - [1.3774155, 0.91556716], - [0.94389069, 2.74940766], - [2.10767177, 7.22065509], - [0.82331252, 0.8250163], - [1.22912089, 0.2194518], - [-2.65530111, 0.9666511], - [0.15784599, 0.99960958], - [-0.43631897, 0.66055387], - [0.70501476, 0.66301126], - [0.72895263, 0.33074653], - [3.47490723, 12.5630275], - [3.14674773, 13.81447167], - [3.51793514, 10.46943904], - [3.94435195, 15.24142224], - [-0.48353674, 0.50215652], - [0.64316089, 6.81513544], - [-0.82957845, 0.80903798], - [-3.4617439, 1.10389229], - [0.2218012, 1.76299192], - [-0.54253359, 0.94968438], - [-1.70841274, 0.61708188], - [-0.44040451, 0.77602089], - [0.13813459, 1.02279698], - [7.57827303, 40.70985885], - [7.55791925, 35.94093086], - [7.10977399, 45.84310211], - [0.05730784, 1.75335899], - [1.52672644, 8.82803475], - [-1.48288999, 0.22412958], - [-2.84526533, 4.49585828], - [-2.41633786, 1.46528758], - [-5.87118328, 5.34300766], - [-5.42854833, 5.1694065], - [-16.34459211, 0.9397118]])) + np.array([[0.2591055, 3.15861149], + [1.3811996, 0.91806814], + [0.94648379, 2.75695426], + [2.11346208, 7.24045853], + [0.82557436, 0.82727771], + [1.23249759, 0.22004329], + [-2.66259589, 0.96925352], + [0.15827963, 1.00235557], + [-0.43751765, 0.66236714], + [0.70695162, 0.66482897], + [0.73095525, 0.33165117], + [3.48445368, 12.59745018], + [3.15539264, 13.85234879], + [3.52759979, 10.49810783], + [3.95518808, 15.28317686], + [-0.48486514, 0.5035343], + [0.64492781, 6.83385521], + [-0.83185751, 0.81125541], + [-3.47125418, 1.10683451], + [0.22241054, 1.76783493], + [-0.54402406, 0.95229119], + [-1.71310618, 0.61875513], + [-0.44161441, 0.77815135], + [0.13851408, 1.02560672], + [7.59909246, 40.82126568], + [7.57868277, 36.03923856], + [7.12930634, 45.96866318], + [0.05746528, 1.75817588], + [1.53092075, 8.85227], + [-1.48696387, 0.22472872], + [-2.853082, 4.50814844], + [-2.42297615, 1.46926902], + [-5.8873129, 5.35742609], + [-5.44346193, 5.18338576], + [-16.38949483, 0.94027717]] + )) np.testing.assert_array_almost_equal(msplot.outliers, np.array( [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, diff --git a/tests/test_outliers.py b/tests/test_outliers.py index d2ebd7037..68e713c69 100644 --- a/tests/test_outliers.py +++ b/tests/test_outliers.py @@ -2,6 +2,7 @@ import numpy as np from skfda import FDataGrid +from skfda.exploratory.depth import modified_band_depth from skfda.exploratory.outliers import directional_outlyingness_stats @@ -13,30 +14,31 @@ def test_directional_outlyingness(self): [[0.2], [0.3], [0.4], [0.5]]] sample_points = [2, 4, 6, 8] fd = FDataGrid(data_matrix, sample_points) - stats = directional_outlyingness_stats(fd) + stats = directional_outlyingness_stats( + fd, depth_method=modified_band_depth) np.testing.assert_allclose(stats.directional_outlyingness, np.array([[[0.], [0.], [0.], [0.]], - [[1.], - [1.], - [1.], - [1.]], + [[0.5], + [0.5], + [0.5], + [0.5]], - [[-0.2], - [-0.2], - [-0.2], - [-0.2]]]), + [[-0.5], + [-0.5], + [-0.5], + [-0.5]]]), rtol=1e-06) np.testing.assert_allclose(stats.mean_directional_outlyingness, np.array([[0.], - [1.5], - [-0.3]]), + [0.5], + [-0.5]]), rtol=1e-06) np.testing.assert_allclose(stats.variation_directional_outlyingness, - np.array([0., 0.375, 0.015])) + np.array([0., 0., 0.]), atol=1e-6) if __name__ == '__main__': From 7c25b112ba734cba52a651be18b1a5bbd0e1305e Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 27 Aug 2019 15:25:28 +0200 Subject: [PATCH 181/222] Fix skdatasets new layout. --- skfda/datasets/_real_datasets.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/skfda/datasets/_real_datasets.py b/skfda/datasets/_real_datasets.py index 9de5fb9d8..0c659621a 100644 --- a/skfda/datasets/_real_datasets.py +++ b/skfda/datasets/_real_datasets.py @@ -1,8 +1,20 @@ -import numpy as np +import warnings + import rdata +import numpy as np + from .. import FDataGrid -import warnings + + +def _get_skdatasets_repositories(): + import skdatasets + + repositories = getattr(skdatasets, "repositories", None) + if repositories is None: + repositories = skdatasets + + return repositories def fdata_constructor(obj, attrs): @@ -53,7 +65,7 @@ def fetch_cran(name, package_name, *, converter=None, package_name: Name of the R package containing the dataset. """ - import skdatasets + repositories = _get_skdatasets_repositories() if converter is None: converter = rdata.conversion.SimpleConverter({ @@ -61,8 +73,8 @@ def fetch_cran(name, package_name, *, converter=None, "fdata": fdata_constructor, "functional": functional_constructor}) - return skdatasets.cran.fetch_dataset(name, package_name, - converter=converter, **kwargs) + return repositories.cran.fetch_dataset(name, package_name, + converter=converter, **kwargs) def fetch_ucr(name, **kwargs): @@ -84,9 +96,9 @@ def fetch_ucr(name, **kwargs): """ - import skdatasets + repositories = _get_skdatasets_repositories() - dataset = skdatasets.ucr.fetch(name, **kwargs) + dataset = repositories.ucr.fetch(name, **kwargs) def ucr_to_fdatagrid(data): if data.dtype == np.object_: From 52aa352521b239a04767db2d29009af485bec043 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 27 Aug 2019 18:01:25 +0200 Subject: [PATCH 182/222] Add asymptotic computation of c and m. --- .../outliers/_directional_outlyingness.py | 70 +++++++++++++++++-- 1 file changed, 65 insertions(+), 5 deletions(-) diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 27d0fb1a4..467d59cfb 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -316,12 +316,14 @@ def __init__(self, *, depth_method=projection_depth, pointwise_weights=None, assume_centered=False, support_fraction=None, + num_resamples=1000, random_state=0, alpha=0.993): self.depth_method = depth_method self.pointwise_weights = pointwise_weights self.assume_centered = assume_centered self.support_fraction = support_fraction + self.num_resamples = num_resamples self.random_state = random_state self.alpha = alpha @@ -341,8 +343,62 @@ def _compute_points(self, X): return points + def _parameters_asymptotic(self, sample_size, dimension): + """Returns the c and m parameters using their asymptotic formula.""" + + n = sample_size + p = dimension + + h = np.floor((n + p + 1) / 2) + + # c estimation + xi_left = scipy.stats.chi2.rvs( + size=self.num_resamples, df=p + 2, random_state=self.random_state_) + xi_right = scipy.stats.ncx2.rvs( + size=self.num_resamples, df=p, nc=h / n, + random_state=self.random_state_) + + c_numerator = np.sum(xi_left < xi_right) / self.num_resamples + c_denominator = h / n + + estimated_c = c_numerator / c_denominator + + # m estimation + alpha = (n - h) / n + q_alpha = scipy.stats.chi2.ppf(1 - alpha, df=p) + + dist_p2 = scipy.stats.chi2.cdf(q_alpha, df=p + 2) + dist_p4 = scipy.stats.chi2.cdf(q_alpha, df=p + 4) + c_alpha = (1 - alpha) / dist_p2 + c2 = -dist_p2 / 2 + c3 = -dist_p4 / 2 + c4 = 3 * c3 + + b1 = (c_alpha * (c3 - c4)) / (1 - alpha) + b2 = (0.5 + c_alpha / (1 - alpha) * + (c3 - q_alpha / p * (c2 + (1 - alpha) / 2))) + + v1 = ((1 - alpha) * b1**2 * (alpha * ( + c_alpha * q_alpha / p - 1) ** 2 - 1) + - 2 * c3 * c_alpha**2 * (3 * (b1 - p * b2)**2 + + (p + 2) * b2 * (2 * b1 - p * b2))) + v2 = n * (b1 * (b1 - p * b2) * (1 - alpha))**2 * c_alpha**2 + v = v1 / v2 + + m_async = 2 / (c_alpha**2 * v) + + estimated_m = (m_async * + np.exp(0.725 - 0.00663 * p - 0.0780 * np.log(n))) + + return estimated_c, estimated_m + def fit_predict(self, X, y=None): + try: + self.random_state_ = np.random.RandomState(self.random_state) + except ValueError: + self.random_state_ = self.random_state + self.points_ = self._compute_points(X) # The square mahalanobis distances of the samples are @@ -350,14 +406,18 @@ def fit_predict(self, X, y=None): self.cov_ = MinCovDet(store_precision=False, assume_centered=self.assume_centered, support_fraction=self.support_fraction, - random_state=self.random_state).fit(self.points_) + random_state=self.random_state_).fit( + self.points_) # Calculation of the degrees of freedom of the F-distribution # (approximation of the tail of the distance distribution). - s_jj = np.diag(self.cov_.covariance_) - c = np.mean(s_jj) - m = 2 / np.square(variation(s_jj)) - p = X.ndim_image + + # One per dimension (mean dir out) plus one (variational dir out) + dimension = X.ndim_codomain + 1 + c, m = self._parameters_asymptotic( + sample_size=X.nsamples, + dimension=dimension) + p = dimension dfn = p + 1 dfd = m - p From 4a59ba9bf2bd7e56518b778d44b2ed9eeebe5ba2 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 28 Aug 2019 11:26:47 +0200 Subject: [PATCH 183/222] Add parameters computed experimentally for ms outlier detection. --- .../outliers/_directional_outlyingness.py | 54 ++++++++--- ...ctional_outlyingness_experiment_results.py | 93 +++++++++++++++++++ tests/test_magnitude_shape.py | 2 +- 3 files changed, 134 insertions(+), 15 deletions(-) create mode 100644 skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 467d59cfb..5b63f3702 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -2,7 +2,7 @@ from numpy import linalg as la import scipy.integrate -from scipy.stats import f, variation +from scipy.stats import f import scipy.stats from sklearn.base import BaseEstimator, OutlierMixin from sklearn.covariance import MinCovDet @@ -10,6 +10,7 @@ import numpy as np from skfda.exploratory.depth.multivariate import projection_depth +from . import _directional_outlyingness_experiment_results as experiments from ... import FDataGrid @@ -344,7 +345,7 @@ def _compute_points(self, X): return points def _parameters_asymptotic(self, sample_size, dimension): - """Returns the c and m parameters using their asymptotic formula.""" + """Return the scaling and cutoff parameters via asymptotic formula.""" n = sample_size p = dimension @@ -385,12 +386,45 @@ def _parameters_asymptotic(self, sample_size, dimension): v2 = n * (b1 * (b1 - p * b2) * (1 - alpha))**2 * c_alpha**2 v = v1 / v2 - m_async = 2 / (c_alpha**2 * v) + m_asympt = 2 / (c_alpha**2 * v) - estimated_m = (m_async * + estimated_m = (m_asympt * np.exp(0.725 - 0.00663 * p - 0.0780 * np.log(n))) - return estimated_c, estimated_m + dfn = p + dfd = estimated_m - p + 1 + + # Calculation of the cutoff value and scaling factor to identify + # outliers. + scaling = estimated_c * dfd / estimated_m / dfn + cutoff_value = f.ppf(self.alpha, dfn, dfd, loc=0, scale=1) + + return scaling, cutoff_value + + def _parameters_numeric(self, sample_size, dimension): + + key = sample_size // 5 + + use_asympt = True + + if dimension == 2: + scaling_list = experiments.dim2_scaling_list + cutoff_list = experiments.dim2_cutoff_list + assert len(scaling_list) == len(cutoff_list) + if key < len(scaling_list): + use_asympt = False + + elif dimension == 3: + scaling_list = experiments.dim3_scaling_list + cutoff_list = experiments.dim3_cutoff_list + assert len(scaling_list) == len(cutoff_list) + if key < len(scaling_list): + use_asympt = False + + if use_asympt: + return self._parameters_asymptotic(sample_size, dimension) + else: + return scaling_list[key], cutoff_list[key] def fit_predict(self, X, y=None): @@ -414,17 +448,9 @@ def fit_predict(self, X, y=None): # One per dimension (mean dir out) plus one (variational dir out) dimension = X.ndim_codomain + 1 - c, m = self._parameters_asymptotic( + self.scaling_, self.cutoff_value_ = self._parameters_numeric( sample_size=X.nsamples, dimension=dimension) - p = dimension - dfn = p + 1 - dfd = m - p - - # Calculation of the cutoff value and scaling factor to identify - # outliers. - self.cutoff_value_ = f.ppf(self.alpha, dfn, dfd, loc=0, scale=1) - self.scaling_ = c * dfd / m / dfn rmd_2 = self.cov_.mahalanobis(self.points_) diff --git a/skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py b/skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py new file mode 100644 index 000000000..bbcdeb580 --- /dev/null +++ b/skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py @@ -0,0 +1,93 @@ +# flake8: noqa + +dim2_scaling_list = [ + 0.2121335, 0.1253451, 0.1565217, 0.1547636, 0.1622325, 0.1505572, 0.1612500, 0.1588387, 0.1604616, 0.1525656, + 0.1633614, 0.1594639, 0.1588519, 0.1556130, 0.1549800, 0.1568933, 0.1565923, 0.1567227, 0.1570298, 0.1550760, + 0.1574286, 0.1556016, 0.1560977, 0.1544471, 0.1568511, 0.1551243, 0.1555905, 0.1554369, 0.1551834, 0.1563881, + 0.1578556, 0.1548095, 0.1548277, 0.1564296, 0.1578730, 0.1525347, 0.1567115, 0.1576655, 0.1558116, 0.1541068, + 0.1545611, 0.1554670, 0.1568173, 0.1542173, 0.1571079, 0.1544965, 0.1533230, 0.1533824, 0.1548096, 0.1537704, + 0.1554282, 0.1540624, 0.1568023, 0.1548350, 0.1557785, 0.1550976, 0.1560173, 0.1544323, 0.1557030, 0.1551104, + 0.1561544, 0.1555108, 0.1554711, 0.1547235, 0.1558244, 0.1542031, 0.1541034, 0.1556838, 0.1564381, 0.1552990, + 0.1558563, 0.1544980, 0.1526366, 0.1543319, 0.1550020, 0.1554849, 0.1550674, 0.1526624, 0.1554895, 0.1544637, + 0.1552222, 0.1538193, 0.1558909, 0.1542087, 0.1560458, 0.1538262, 0.1543163, 0.1561091, 0.1532372, 0.1525664, + 0.1539353, 0.1548102, 0.1557905, 0.1542451, 0.1541179, 0.1556096, 0.1547721, 0.1546924, 0.1554716, 0.1543489, + 0.1546151, 0.1533572, 0.1562669, 0.1528163, 0.1534297, 0.1539997, 0.1528505, 0.1545928, 0.1543968, 0.1538527, + 0.1546153, 0.1540841, 0.1542552, 0.1531663, 0.1544166, 0.1544635, 0.1540585, 0.1538253, 0.1548289, 0.1532696, + 0.1544817, 0.1531042, 0.1548255, 0.1549259, 0.1545279, 0.1543917, 0.1542263, 0.1548069, 0.1546739, 0.1548979, + 0.1544538, 0.1549110, 0.1547474, 0.1554628, 0.1541283, 0.1543357, 0.1532495, 0.1546531, 0.1536372, 0.1534868, + 0.1550233, 0.1549184, 0.1547942, 0.1531549, 0.1547432, 0.1540121, 0.1539858, 0.1540275, 0.1540514, 0.1545998, + 0.1547139, 0.1542179, 0.1543705, 0.1538858, 0.1533302, 0.1543564, 0.1541848, 0.1544018, 0.1550464, 0.1541978, + 0.1539613, 0.1539414, 0.1537023, 0.1541610, 0.1539085, 0.1546158, 0.1533954, 0.1541077, 0.1539469, 0.1540911, + 0.1535694, 0.1537211, 0.1561847, 0.1547216, 0.1550860, 0.1542807, 0.1539105, 0.1536416, 0.1551650, 0.1546976, + 0.1541528, 0.1530584, 0.1535215, 0.1528372, 0.1547732, 0.1541007, 0.1527553, 0.1530790, 0.1537915, 0.1557236, + 0.1542955, 0.1546599, 0.1539200, 0.1543129, 0.1546367, 0.1540998, 0.1545750, 0.1537620, 0.1545902, 0.1533918 +] + +dim2_cutoff_list = [ + 6286.806503, 7069.303491, 203.754509, 53.868064, 36.680902, 24.125505, 19.502014, 14.371712, 11.942886, 13.409540, + 11.666568, 11.211317, 9.666969, 9.882178, 9.858427, 9.008749, 10.421072, 8.617650, 7.787332, 8.870144, + 8.282617, 8.877598, 8.258887, 7.932080, 7.903199, 7.180405, 7.117186, 7.047117, 7.121083, 7.194535, + 7.126350, 6.786685, 6.895241, 6.706737, 7.015522, 6.786461, 6.566930, 6.604725, 6.580303, 6.418914, + 6.627840, 6.542344, 6.554969, 6.339228, 6.397994, 6.439657, 6.335521, 6.748376, 6.192320, 6.382786, + 6.671146, 6.389191, 6.228198, 6.083829, 6.302724, 6.243392, 6.026669, 6.362434, 6.161796, 5.942257, + 6.212520, 6.171110, 5.999409, 5.956502, 6.015412, 5.868389, 6.142839, 5.874490, 5.898274, 6.038861, + 5.895112, 5.808560, 5.910406, 5.834951, 5.800549, 5.841627, 5.897550, 5.908135, 5.757326, 5.750881, + 5.695624, 5.893221, 5.680075, 5.821171, 5.695872, 5.853736, 5.663726, 5.660303, 5.634178, 5.722455, + 5.685123, 5.719192, 5.529153, 5.699716, 5.673074, 5.706664, 5.625897, 5.567204, 5.693370, 5.649847, + 5.586466, 5.709425, 5.658531, 5.650578, 5.681310, 5.583404, 5.633103, 5.562921, 5.470083, 5.591973, + 5.550243, 5.542991, 5.620563, 5.546626, 5.563986, 5.448158, 5.593792, 5.487137, 5.520841, 5.652330, + 5.554568, 5.514855, 5.406874, 5.620458, 5.468423, 5.478852, 5.475199, 5.437369, 5.437442, 5.468733, + 5.412066, 5.439402, 5.399972, 5.451357, 5.490837, 5.496724, 5.455286, 5.397668, 5.457133, 5.419380, + 5.415404, 5.402248, 5.384233, 5.458364, 5.404613, 5.499005, 5.496800, 5.444763, 5.416436, 5.370227, + 5.410253, 5.425907, 5.410008, 5.457504, 5.374235, 5.416893, 5.386821, 5.416472, 5.414746, 5.386966, + 5.351314, 5.301842, 5.384568, 5.372480, 5.308955, 5.417455, 5.371718, 5.331880, 5.401622, 5.461437, + 5.385109, 5.320139, 5.414657, 5.303942, 5.402835, 5.358076, 5.407760, 5.337252, 5.314391, 5.303956, + 5.399311, 5.277304, 5.297922, 5.371853, 5.292200, 5.380042, 5.342089, 5.351534, 5.275807, 5.253204, + 5.316196, 5.271798, 5.311633, 5.330840, 5.309106, 5.291427, 5.252260, 5.291768, 5.305976, 5.258753 +] + +dim3_scaling_list = [ + 0.1187609, 0.1097623, 0.1101663, 0.1162165, 0.1213028, 0.1206332, 0.1302320, 0.1214673, 0.1306922, 0.1234858, + 0.1288665, 0.1306923, 0.1300261, 0.1261992, 0.1272822, 0.1317683, 0.1326661, 0.1279338, 0.1333107, 0.1303142, + 0.1298897, 0.1317839, 0.1330671, 0.1298334, 0.1318497, 0.1315813, 0.1330811, 0.1333595, 0.1330582, 0.1315602, + 0.1351193, 0.1326117, 0.1326702, 0.1307535, 0.1352103, 0.1322230, 0.1353843, 0.1338843, 0.1332325, 0.1330464, + 0.1329135, 0.1314229, 0.1341303, 0.1326243, 0.1341636, 0.1337687, 0.1341612, 0.1338898, 0.1347098, 0.1343578, + 0.1347368, 0.1339320, 0.1337794, 0.1336984, 0.1357062, 0.1346642, 0.1341837, 0.1335938, 0.1329890, 0.1323547, + 0.1349196, 0.1343208, 0.1337998, 0.1349249, 0.1335854, 0.1338366, 0.1347434, 0.1341192, 0.1358149, 0.1333822, + 0.1351180, 0.1349074, 0.1337441, 0.1331300, 0.1339912, 0.1342523, 0.1340527, 0.1352488, 0.1341760, 0.1334394, + 0.1351225, 0.1348562, 0.1344585, 0.1336557, 0.1365562, 0.1339065, 0.1352742, 0.1342251, 0.1346831, 0.1349060, + 0.1355161, 0.1340021, 0.1356369, 0.1337264, 0.1347072, 0.1345369, 0.1345749, 0.1362518, 0.1350790, 0.1350154, + 0.1350179, 0.1345847, 0.1344746, 0.1344235, 0.1346353, 0.1343059, 0.1349473, 0.1340332, 0.1350443, 0.1350253, + 0.1351322, 0.1342308, 0.1341750, 0.1347789, 0.1344972, 0.1348065, 0.1353910, 0.1350845, 0.1346201, 0.1348159, + 0.1362330, 0.1342854, 0.1354829, 0.1350856, 0.1348560, 0.1351210, 0.1343983, 0.1354382, 0.1347086, 0.1344355, + 0.1349586, 0.1340182, 0.1345923, 0.1338932, 0.1346312, 0.1355021, 0.1353207, 0.1341761, 0.1352211, 0.1349323, + 0.1351921, 0.1347820, 0.1351046, 0.1339337, 0.1354037, 0.1353637, 0.1360710, 0.1347368, 0.1342498, 0.1348067, + 0.1357422, 0.1342925, 0.1350971, 0.1347161, 0.1347490, 0.1349758, 0.1359770, 0.1347644, 0.1349472, 0.1348542, + 0.1352712, 0.1352447, 0.1352783, 0.1348189, 0.1358087, 0.1344338, 0.1355165, 0.1346229, 0.1347777, 0.1351525, + 0.1353600, 0.1346924, 0.1347265, 0.1355599, 0.1356250, 0.1353967, 0.1346180, 0.1356917, 0.1354474, 0.1353324, + 0.1352965, 0.1349137, 0.1351970, 0.1349425, 0.1354943, 0.1353744, 0.1355507, 0.1344087, 0.1350529, 0.1346244, + 0.1339703, 0.1354726, 0.1342863, 0.1359491, 0.1352492, 0.1356447, 0.1362790, 0.1350377, 0.1350933, 0.1350587 +] + +dim3_cutoff_list = [ + 206.029191, 146.889509, 50.543692, 27.278366, 16.707939, 14.434913, 11.342958, 11.098252, 8.789144, 9.597229, + 8.247667, 7.704991, 7.695929, 7.391011, 6.844833, 6.708396, 6.549818, 6.474814, 5.987010, 6.609382, + 6.534536, 6.139765, 5.956961, 6.211978, 5.920700, 5.611180, 5.338665, 5.484496, 5.410620, 5.521601, + 5.329094, 5.292734, 5.302679, 5.411211, 5.124806, 5.235160, 5.121247, 5.222986, 5.044353, 5.070738, + 5.096666, 5.047837, 4.977791, 4.968391, 4.906041, 4.940546, 5.028766, 4.902459, 4.955797, 4.844349, + 4.866510, 4.783062, 4.912152, 4.741653, 4.771211, 4.764672, 4.766674, 4.813230, 4.836257, 4.791624, + 4.721716, 4.740022, 4.770690, 4.686971, 4.716747, 4.751644, 4.706984, 4.691544, 4.675819, 4.655545, + 4.627139, 4.622657, 4.611972, 4.649849, 4.572692, 4.605395, 4.584190, 4.594377, 4.599613, 4.523968, + 4.600675, 4.568919, 4.553310, 4.576655, 4.526107, 4.582538, 4.574265, 4.573723, 4.567293, 4.474265, + 4.475604, 4.531034, 4.461954, 4.512774, 4.492671, 4.443831, 4.467436, 4.463272, 4.445596, 4.468457, + 4.470040, 4.483412, 4.461647, 4.485051, 4.489471, 4.487105, 4.454762, 4.451565, 4.400235, 4.427561, + 4.454393, 4.463734, 4.459003, 4.417900, 4.404131, 4.417031, 4.365372, 4.389499, 4.391211, 4.463709, + 4.392652, 4.446539, 4.382480, 4.420110, 4.407877, 4.428912, 4.395465, 4.387521, 4.387975, 4.405238, + 4.367305, 4.412212, 4.345672, 4.390639, 4.354048, 4.348921, 4.418925, 4.389510, 4.370910, 4.359586, + 4.362771, 4.349929, 4.348037, 4.353366, 4.343536, 4.358311, 4.342201, 4.368329, 4.374955, 4.348660, + 4.326858, 4.331110, 4.359202, 4.343692, 4.351122, 4.340177, 4.294180, 4.307192, 4.328342, 4.318618, + 4.350062, 4.316514, 4.326162, 4.325069, 4.326072, 4.321784, 4.319809, 4.324667, 4.308963, 4.324538, + 4.289233, 4.294861, 4.326967, 4.306770, 4.283400, 4.290556, 4.323467, 4.289662, 4.301906, 4.272647, + 4.261544, 4.321419, 4.273228, 4.275232, 4.284952, 4.284651, 4.269343, 4.265331, 4.288159, 4.262913, + 4.285571, 4.253609, 4.297223, 4.275776, 4.281020, 4.285923, 4.265539, 4.288701, 4.265320, 4.269609 +] diff --git a/tests/test_magnitude_shape.py b/tests/test_magnitude_shape.py index 8118fd234..d2a1265ad 100644 --- a/tests/test_magnitude_shape.py +++ b/tests/test_magnitude_shape.py @@ -60,7 +60,7 @@ def test_magnitude_shape_plot(self): [0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, 0, - 0, 0, 1, 0, 1])) + 0, 0, 0, 0, 1])) if __name__ == '__main__': From 8a3f628c59a51ab1ba97b1032b09f76fc1101b2a Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 28 Aug 2019 11:43:39 +0200 Subject: [PATCH 184/222] Fix ms points for more than one dimension. --- .../outliers/_directional_outlyingness.py | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 5b63f3702..aee52f6f6 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -245,18 +245,8 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): where :math:`p` is the dimension of the image, and :math:`c` and :math:`m` are parameters determining the degrees of freedom of the - :math:`F`-distribution and the scaling factor. - - .. math:: - c = E \left[s^*_{jj}\right] - - where :math:`s^*_{jj}` are the diagonal elements of MCD and - - .. math:: - m = \frac{2}{CV^2} - - where :math:`CV` is the estimated coefficient of variation of the diagonal - elements of the MCD shape estimator. + :math:`F`-distribution and the scaling factor, given by empirical results + and an asymptotic formula. Finally, we choose a cutoff value to determine the outliers, C , as the :math:`\alpha` quantile of :math:`F_{p+1, m-p}`. We set @@ -335,12 +325,8 @@ def _compute_points(self, X): depth_method=self.depth_method, pointwise_weights=self.pointwise_weights) - points = np.array( - list( - zip( - mean_dir_outl.ravel(), variation_dir_outl - ) - )) + points = np.concatenate((mean_dir_outl, + variation_dir_outl[:, np.newaxis]), axis=1) return points From 8790f438fb148e543b569ac8a09e7a9ad5f0d840 Mon Sep 17 00:00:00 2001 From: Sean Johnsen Date: Wed, 28 Aug 2019 09:53:29 -0400 Subject: [PATCH 185/222] Correct 'amplitude' -> 'phase' in phase_distance function documentation --- skfda/misc/metrics.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index e5a434c45..de63ac1d7 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -491,7 +491,7 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, **kwargs): - r"""Compute the amplitude distance btween two functional objects. + r"""Compute the phase distance btween two functional objects. Let :math:`f_i` and :math:`f_j` be two functional observations, and let :math:`\gamma_{ij}` the corresponding warping used in the elastic From 3c58598f790f0efd23ea0eea0753d7ad486d57da Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 27 Aug 2019 15:25:28 +0200 Subject: [PATCH 186/222] Fix skdatasets new layout. --- skfda/datasets/_real_datasets.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/skfda/datasets/_real_datasets.py b/skfda/datasets/_real_datasets.py index 9de5fb9d8..0c659621a 100644 --- a/skfda/datasets/_real_datasets.py +++ b/skfda/datasets/_real_datasets.py @@ -1,8 +1,20 @@ -import numpy as np +import warnings + import rdata +import numpy as np + from .. import FDataGrid -import warnings + + +def _get_skdatasets_repositories(): + import skdatasets + + repositories = getattr(skdatasets, "repositories", None) + if repositories is None: + repositories = skdatasets + + return repositories def fdata_constructor(obj, attrs): @@ -53,7 +65,7 @@ def fetch_cran(name, package_name, *, converter=None, package_name: Name of the R package containing the dataset. """ - import skdatasets + repositories = _get_skdatasets_repositories() if converter is None: converter = rdata.conversion.SimpleConverter({ @@ -61,8 +73,8 @@ def fetch_cran(name, package_name, *, converter=None, "fdata": fdata_constructor, "functional": functional_constructor}) - return skdatasets.cran.fetch_dataset(name, package_name, - converter=converter, **kwargs) + return repositories.cran.fetch_dataset(name, package_name, + converter=converter, **kwargs) def fetch_ucr(name, **kwargs): @@ -84,9 +96,9 @@ def fetch_ucr(name, **kwargs): """ - import skdatasets + repositories = _get_skdatasets_repositories() - dataset = skdatasets.ucr.fetch(name, **kwargs) + dataset = repositories.ucr.fetch(name, **kwargs) def ucr_to_fdatagrid(data): if data.dtype == np.object_: From 4c53ffa658485efce3bb121c6df1fbb9640bc46b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 29 Aug 2019 15:53:40 +0200 Subject: [PATCH 187/222] Add Scipy greater than 1.3.0 to dependencies. --- setup.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index 904718482..82ebce2ac 100644 --- a/setup.py +++ b/setup.py @@ -23,12 +23,13 @@ import os import sys -import numpy as np - +from Cython.Build import cythonize +from Cython.Distutils import build_ext from setuptools import setup, find_packages from setuptools.extension import Extension -from Cython.Distutils import build_ext -from Cython.Build import cythonize + +import numpy as np + needs_pytest = {'pytest', 'test', 'ptr'}.intersection(sys.argv) pytest_runner = ['pytest-runner'] if needs_pytest else [] @@ -80,6 +81,7 @@ 'Topic :: Software Development :: Libraries :: Python Modules', ], install_requires=['numpy', + 'scipy>=1.3.0', 'scikit-learn', 'matplotlib', 'scikit-datasets[cran]>=0.1.24', From 25a09c57a3a259d4a4df9506b6f4af06e4d24ad5 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 29 Aug 2019 15:58:02 +0200 Subject: [PATCH 188/222] Move import of the experimental results into the function that uses them. --- skfda/exploratory/outliers/_directional_outlyingness.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index aee52f6f6..639d30e3b 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -10,7 +10,6 @@ import numpy as np from skfda.exploratory.depth.multivariate import projection_depth -from . import _directional_outlyingness_experiment_results as experiments from ... import FDataGrid @@ -388,6 +387,8 @@ def _parameters_asymptotic(self, sample_size, dimension): return scaling, cutoff_value def _parameters_numeric(self, sample_size, dimension): + from . import \ + _directional_outlyingness_experiment_results as experiments key = sample_size // 5 From 0fafea0fb1b22a6167a23f446dd9b345d16e2568 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 29 Aug 2019 16:50:00 +0200 Subject: [PATCH 189/222] Import by default the multivariate depths and fix documentation. Some references to visualization methods and examples have been fixed. --- docs/modules/exploratory/depth.rst | 23 +++++++++++++++++++ .../exploratory/visualization/boxplot.rst | 17 ++++---------- .../visualization/magnitude_shape_plot.rst | 15 ++++++------ examples/plot_boxplot.py | 2 +- examples/plot_magnitude_shape.py | 3 +-- examples/plot_magnitude_shape_synthetic.py | 3 +-- examples/plot_surface_boxplot.py | 2 +- skfda/exploratory/depth/__init__.py | 1 + skfda/exploratory/depth/_depth.py | 4 ++-- .../outliers/_directional_outlyingness.py | 4 ++-- skfda/exploratory/visualization/__init__.py | 4 ++-- .../visualization/{boxplot.py => _boxplot.py} | 0 ...shape_plot.py => _magnitude_shape_plot.py} | 0 tests/test_fdata_boxplot.py | 5 ++-- tests/test_magnitude_shape.py | 3 +-- 15 files changed, 50 insertions(+), 36 deletions(-) rename skfda/exploratory/visualization/{boxplot.py => _boxplot.py} (100%) rename skfda/exploratory/visualization/{magnitude_shape_plot.py => _magnitude_shape_plot.py} (100%) diff --git a/docs/modules/exploratory/depth.rst b/docs/modules/exploratory/depth.rst index 98195f993..ded94532f 100644 --- a/docs/modules/exploratory/depth.rst +++ b/docs/modules/exploratory/depth.rst @@ -21,5 +21,28 @@ is given if a parameter is specified in the functions. All of them support multivariate functional data, with more than one dimension on the image and on the domain. +Outlyingness conversion to depth +-------------------------------- + +The concepts of depth and outlyingness are (inversely) related. A deeper datum is less likely an outlier. Conversely, +a datum with very low depth is possibly an outlier. In order to convert an outlying measure to a depth measure +the following convenience function is provided. + +.. autosummary:: + :toctree: autosummary + + skfda.exploratory.depth.outlyingness_to_depth + +Multivariate depths +------------------- + +Some utilities, such as the :class:`~skfda.exploratory.visualization.MagnitudeShapePlot` require computing a non-functional +(multivariate) depth pointwise. Thus we also provide some multivariate depth functions. + +.. autosummary:: + :toctree: autosummary + + skfda.exploratory.depth.multivariate.projection_depth + diff --git a/docs/modules/exploratory/visualization/boxplot.rst b/docs/modules/exploratory/visualization/boxplot.rst index 12f2237eb..f5284e27b 100644 --- a/docs/modules/exploratory/visualization/boxplot.rst +++ b/docs/modules/exploratory/visualization/boxplot.rst @@ -5,28 +5,21 @@ Classes to construct the functional data boxplot. Only supported for functional data with domain dimension 1 or 2 and as many dimensions on the image as required. -The base abstract class from which the others inherit is FDataBoxplot. - -.. autosummary:: - :toctree: autosummary - - skfda.exploratory.visualization.boxplot.FDataBoxplot - If the dimension of the domain is 1, the following class must be used. -See `Boxplot Example <../auto_examples/plot_boxplot.html>`_ for detailed explanation. +See the :ref:`sphx_glr_auto_examples_plot_boxplot.py` example for detailed explanation. .. autosummary:: :toctree: autosummary - skfda.exploratory.visualization.boxplot.Boxplot + skfda.exploratory.visualization.Boxplot -If the dimension of the domain is 2, this one. See `Surface Boxplot Example -<../auto_examples/plot_surface_boxplot.html>`_ for detailed explanation. +If the dimension of the domain is 2, this one. See the :ref:`sphx_glr_auto_examples_plot_surface_boxplot.py` +example for detailed explanation. .. autosummary:: :toctree: autosummary - skfda.exploratory.visualization.boxplot.SurfaceBoxplot + skfda.exploratory.visualization.SurfaceBoxplot diff --git a/docs/modules/exploratory/visualization/magnitude_shape_plot.rst b/docs/modules/exploratory/visualization/magnitude_shape_plot.rst index f6330adac..17668c2ed 100644 --- a/docs/modules/exploratory/visualization/magnitude_shape_plot.rst +++ b/docs/modules/exploratory/visualization/magnitude_shape_plot.rst @@ -1,18 +1,19 @@ Magnitude-Shape Plot ==================== -The Magnitude-Shape Plot is implemented in the :class:`MagnitudeShapePlot` class. +The Magnitude-Shape Plot is implemented in the +:class:`~skfda.exploratory.visualization.MagnitudeShapePlot` class. -The :class:`MagnitudeShapePlot` needs both the mean and the variation of the -directional outlyingness of the samples, which is calculated using -:func:`directional_outlyingness_stats`. +The :class:`~skfda.exploratory.visualization.MagnitudeShapePlot` needs both the mean +and the variation of the directional outlyingness of the samples, which is calculated using +:func:`~skfda.exploratory.outliers.directional_outlyingness_stats`. Once the points assigned to each of the samples are obtained from the above function, an outlier detection method is implemented. The results can be shown -calling the :func:`plot method ` -of the class. +calling the :meth:`~skfda.magnitude_shape_plot.MagnitudeShapePlot.plot` +method of the class. .. autosummary:: :toctree: autosummary - skfda.exploratory.visualization.magnitude_shape_plot.MagnitudeShapePlot + skfda.exploratory.visualization.MagnitudeShapePlot diff --git a/examples/plot_boxplot.py b/examples/plot_boxplot.py index 3d6188d05..cac135b00 100644 --- a/examples/plot_boxplot.py +++ b/examples/plot_boxplot.py @@ -15,7 +15,7 @@ import numpy as np from skfda import datasets from skfda.exploratory.depth import band_depth, fraiman_muniz_depth -from skfda.exploratory.visualization.boxplot import Boxplot +from skfda.exploratory.visualization import Boxplot ############################################################################## diff --git a/examples/plot_magnitude_shape.py b/examples/plot_magnitude_shape.py index b12e06770..0975cc6ce 100644 --- a/examples/plot_magnitude_shape.py +++ b/examples/plot_magnitude_shape.py @@ -14,8 +14,7 @@ import numpy as np from skfda import datasets from skfda.exploratory.depth import fraiman_muniz_depth, modified_band_depth -from skfda.exploratory.visualization.magnitude_shape_plot import ( - MagnitudeShapePlot) +from skfda.exploratory.visualization import MagnitudeShapePlot ############################################################################## diff --git a/examples/plot_magnitude_shape_synthetic.py b/examples/plot_magnitude_shape_synthetic.py index 4adc1a0fe..530a90891 100644 --- a/examples/plot_magnitude_shape_synthetic.py +++ b/examples/plot_magnitude_shape_synthetic.py @@ -13,8 +13,7 @@ import matplotlib.pyplot as plt import numpy as np import skfda -from skfda.exploratory.visualization.magnitude_shape_plot import ( - MagnitudeShapePlot) +from skfda.exploratory.visualization import MagnitudeShapePlot ############################################################################## diff --git a/examples/plot_surface_boxplot.py b/examples/plot_surface_boxplot.py index b56f26489..afab2bd67 100644 --- a/examples/plot_surface_boxplot.py +++ b/examples/plot_surface_boxplot.py @@ -15,7 +15,7 @@ import numpy as np from skfda import FDataGrid from skfda.datasets import make_sinusoidal_process, make_gaussian_process -from skfda.exploratory.visualization.boxplot import SurfaceBoxplot, Boxplot +from skfda.exploratory.visualization import SurfaceBoxplot, Boxplot ############################################################################## diff --git a/skfda/exploratory/depth/__init__.py b/skfda/exploratory/depth/__init__.py index 931a0c837..78e552ff0 100644 --- a/skfda/exploratory/depth/__init__.py +++ b/skfda/exploratory/depth/__init__.py @@ -2,3 +2,4 @@ modified_band_depth, fraiman_muniz_depth, outlyingness_to_depth) +from . import multivariate diff --git a/skfda/exploratory/depth/_depth.py b/skfda/exploratory/depth/_depth.py index 3ce990acc..263bfe987 100644 --- a/skfda/exploratory/depth/_depth.py +++ b/skfda/exploratory/depth/_depth.py @@ -41,8 +41,8 @@ def outlyingness_to_depth(outlyingness, *, supreme=None): References: .. [Se06] Serfling, R. (2006). Depth functions in nonparametric - multivariate inference. DIMACS Series in Discrete Mathematics and - Theoretical Computer Science, 72, 1. + multivariate inference. DIMACS Series in Discrete Mathematics and + Theoretical Computer Science, 72, 1. """ if supreme is None or math.isinf(supreme): diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 639d30e3b..7015154cd 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -242,8 +242,8 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): \frac{c\left(m - p\right)}{m\left(p + 1\right)}RMD^2\left( \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right)\sim F_{p+1, m-p} - where :math:`p` is the dimension of the image, and :math:`c` and :math:`m` - are parameters determining the degrees of freedom of the + where :math:`p` is the dimension of the image plus one, and :math:`c` and + :math:`m` are parameters determining the degrees of freedom of the :math:`F`-distribution and the scaling factor, given by empirical results and an asymptotic formula. diff --git a/skfda/exploratory/visualization/__init__.py b/skfda/exploratory/visualization/__init__.py index f9855b2a7..3824be9aa 100644 --- a/skfda/exploratory/visualization/__init__.py +++ b/skfda/exploratory/visualization/__init__.py @@ -1,2 +1,2 @@ -from .boxplot import Boxplot, SurfaceBoxplot -from .magnitude_shape_plot import MagnitudeShapePlot +from ._boxplot import Boxplot, SurfaceBoxplot +from ._magnitude_shape_plot import MagnitudeShapePlot diff --git a/skfda/exploratory/visualization/boxplot.py b/skfda/exploratory/visualization/_boxplot.py similarity index 100% rename from skfda/exploratory/visualization/boxplot.py rename to skfda/exploratory/visualization/_boxplot.py diff --git a/skfda/exploratory/visualization/magnitude_shape_plot.py b/skfda/exploratory/visualization/_magnitude_shape_plot.py similarity index 100% rename from skfda/exploratory/visualization/magnitude_shape_plot.py rename to skfda/exploratory/visualization/_magnitude_shape_plot.py diff --git a/tests/test_fdata_boxplot.py b/tests/test_fdata_boxplot.py index d9fd44f2a..63e011730 100644 --- a/tests/test_fdata_boxplot.py +++ b/tests/test_fdata_boxplot.py @@ -1,10 +1,9 @@ import unittest -import matplotlib.pyplot as plt import numpy as np from skfda import FDataGrid -from skfda.exploratory.depth import band_depth, fraiman_muniz_depth -from skfda.exploratory.visualization.boxplot import Boxplot, SurfaceBoxplot +from skfda.exploratory.depth import fraiman_muniz_depth +from skfda.exploratory.visualization import Boxplot, SurfaceBoxplot class TestBoxplot(unittest.TestCase): diff --git a/tests/test_magnitude_shape.py b/tests/test_magnitude_shape.py index d2a1265ad..77fd0a4d5 100644 --- a/tests/test_magnitude_shape.py +++ b/tests/test_magnitude_shape.py @@ -4,8 +4,7 @@ from skfda import FDataGrid from skfda.datasets import fetch_weather from skfda.exploratory.depth import modified_band_depth -from skfda.exploratory.visualization.magnitude_shape_plot import ( - MagnitudeShapePlot) +from skfda.exploratory.visualization import MagnitudeShapePlot class TestMagnitudeShapePlot(unittest.TestCase): From 0e25f13d349922efd5355f84f2f3a05f947e7171 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 29 Aug 2019 17:00:48 +0200 Subject: [PATCH 190/222] Added references from experimental data. --- .../_directional_outlyingness_experiment_results.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py b/skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py index bbcdeb580..846c962d1 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py +++ b/skfda/exploratory/outliers/_directional_outlyingness_experiment_results.py @@ -1,5 +1,16 @@ # flake8: noqa +# This data is from a simulation program in [HaRo2005]_. The data was taken +# from the supplementary material in [DaGe2018]_. +# +# References: +# .. [DaGe2018] Dai, Wenlin, and Genton, Marc G. "Multivariate functional data +# visualization and outlier detection." Journal of Computational +# and Graphical Statistics 27.4 (2018): 923-934. +# .. [HaRo2005] Hardin, Johanna, and Rocke, David M. "The distribution of +# robust distances." Journal of Computational and Graphical Statistics +# 14.4 (2005): 928-946. + dim2_scaling_list = [ 0.2121335, 0.1253451, 0.1565217, 0.1547636, 0.1622325, 0.1505572, 0.1612500, 0.1588387, 0.1604616, 0.1525656, 0.1633614, 0.1594639, 0.1588519, 0.1556130, 0.1549800, 0.1568933, 0.1565923, 0.1567227, 0.1570298, 0.1550760, From 37d9214644d9daf02cded57256995ceab5835cae Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 29 Aug 2019 17:40:29 +0200 Subject: [PATCH 191/222] Add test for asymptotic formula. --- skfda/exploratory/__init__.py | 5 ++-- .../outliers/_directional_outlyingness.py | 28 ++++++++++++------- tests/test_outliers.py | 14 ++++++++++ 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/skfda/exploratory/__init__.py b/skfda/exploratory/__init__.py index b1dbd69ec..7d58f75c6 100644 --- a/skfda/exploratory/__init__.py +++ b/skfda/exploratory/__init__.py @@ -1,3 +1,4 @@ -from . import visualization -from . import stats from . import depth +from . import outliers +from . import stats +from . import visualization diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 7015154cd..4e95ffcc0 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -302,13 +302,15 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): """ - def __init__(self, *, depth_method=projection_depth, - pointwise_weights=None, - assume_centered=False, - support_fraction=None, - num_resamples=1000, - random_state=0, - alpha=0.993): + def __init__( + self, *, depth_method=projection_depth, + pointwise_weights=None, + assume_centered=False, + support_fraction=None, + num_resamples=1000, + random_state=0, + alpha=0.993, + _force_asymptotic=False): self.depth_method = depth_method self.pointwise_weights = pointwise_weights self.assume_centered = assume_centered @@ -316,6 +318,7 @@ def __init__(self, *, depth_method=projection_depth, self.num_resamples = num_resamples self.random_state = random_state self.alpha = alpha + self._force_asymptotic = _force_asymptotic def _compute_points(self, X): # The depths of the samples are calculated giving them an ordering. @@ -435,9 +438,14 @@ def fit_predict(self, X, y=None): # One per dimension (mean dir out) plus one (variational dir out) dimension = X.ndim_codomain + 1 - self.scaling_, self.cutoff_value_ = self._parameters_numeric( - sample_size=X.nsamples, - dimension=dimension) + if self._force_asymptotic: + self.scaling_, self.cutoff_value_ = self._parameters_asymptotic( + sample_size=X.nsamples, + dimension=dimension) + else: + self.scaling_, self.cutoff_value_ = self._parameters_numeric( + sample_size=X.nsamples, + dimension=dimension) rmd_2 = self.cov_.mahalanobis(self.points_) diff --git a/tests/test_outliers.py b/tests/test_outliers.py index 68e713c69..a46abce5d 100644 --- a/tests/test_outliers.py +++ b/tests/test_outliers.py @@ -3,6 +3,7 @@ import numpy as np from skfda import FDataGrid from skfda.exploratory.depth import modified_band_depth +from skfda.exploratory.outliers import DirectionalOutlierDetector from skfda.exploratory.outliers import directional_outlyingness_stats @@ -40,6 +41,19 @@ def test_directional_outlyingness(self): np.testing.assert_allclose(stats.variation_directional_outlyingness, np.array([0., 0., 0.]), atol=1e-6) + def test_asymptotic_formula(self): + data_matrix = [[1, 1, 2, 3, 2.5, 2], + [0.5, 0.5, 1, 2, 1.5, 1], + [-1, -1, -0.5, 1, 1, 0.5], + [-0.5, -0.5, -0.5, -1, -1, -1]] + sample_points = [0, 2, 4, 6, 8, 10] + fd = FDataGrid(data_matrix, sample_points) + out_detector = DirectionalOutlierDetector( + _force_asymptotic=True) + prediction = out_detector.fit_predict(fd) + np.testing.assert_allclose(prediction, + np.array([1, 1, 1, 1])) + if __name__ == '__main__': print() From a7d8bea44b6cea91fa463dcfb31a2cb276a3bbf8 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 30 Aug 2019 09:49:46 +0200 Subject: [PATCH 192/222] Add recursive .gitignore and remove gitignore per module in docs. --- docs/.gitignore | 1 + docs/modules/.gitignore | 1 - docs/modules/exploratory/.gitignore | 1 - docs/modules/exploratory/visualization/.gitignore | 1 - docs/modules/misc/.gitignore | 1 - docs/modules/ml/.gitignore | 1 - docs/modules/preprocessing/.gitignore | 1 - docs/modules/representation/.gitignore | 1 - 8 files changed, 1 insertion(+), 7 deletions(-) delete mode 100644 docs/modules/.gitignore delete mode 100644 docs/modules/exploratory/.gitignore delete mode 100644 docs/modules/exploratory/visualization/.gitignore delete mode 100644 docs/modules/misc/.gitignore delete mode 100644 docs/modules/ml/.gitignore delete mode 100644 docs/modules/preprocessing/.gitignore delete mode 100644 docs/modules/representation/.gitignore diff --git a/docs/.gitignore b/docs/.gitignore index d95b42c06..1588679a9 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -1,2 +1,3 @@ /auto_examples/ /backreferences/ +**/autosummary/ \ No newline at end of file diff --git a/docs/modules/.gitignore b/docs/modules/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ diff --git a/docs/modules/exploratory/.gitignore b/docs/modules/exploratory/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/exploratory/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ diff --git a/docs/modules/exploratory/visualization/.gitignore b/docs/modules/exploratory/visualization/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/exploratory/visualization/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ diff --git a/docs/modules/misc/.gitignore b/docs/modules/misc/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/misc/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ diff --git a/docs/modules/ml/.gitignore b/docs/modules/ml/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/ml/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ diff --git a/docs/modules/preprocessing/.gitignore b/docs/modules/preprocessing/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/preprocessing/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ diff --git a/docs/modules/representation/.gitignore b/docs/modules/representation/.gitignore deleted file mode 100644 index beebbea8e..000000000 --- a/docs/modules/representation/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/autosummary/ From 594fff7d0904f1018cb883e3e4e5756db1fc5c7f Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 30 Aug 2019 10:29:56 +0200 Subject: [PATCH 193/222] Rename nsamples to n_samples --- examples/plot_landmark_registration.py | 16 ++--- examples/plot_landmark_shift.py | 20 +++--- skfda/_neighbors/base.py | 17 ++--- skfda/datasets/_real_datasets.py | 4 +- skfda/exploratory/depth/_depth.py | 22 +++--- .../outliers/_directional_outlyingness.py | 12 ++-- skfda/exploratory/outliers/_envelopes.py | 2 +- skfda/exploratory/visualization/_boxplot.py | 2 +- .../visualization/_magnitude_shape_plot.py | 4 +- .../visualization/clustering_plots.py | 38 +++++----- skfda/misc/_lfd.py | 2 +- skfda/misc/_math.py | 9 +-- skfda/misc/metrics.py | 15 ++-- skfda/ml/clustering/base_kmeans.py | 72 ++++++++++--------- skfda/ml/regression/linear_model.py | 18 ++--- .../dim_reduction/projection/_fpca.py | 5 +- skfda/preprocessing/registration/_elastic.py | 13 ++-- .../registration/_landmark_registration.py | 10 +-- .../registration/_registration_utils.py | 22 +++--- .../registration/_shift_registration.py | 14 ++-- skfda/representation/_functional_data.py | 52 +++++++------- skfda/representation/basis.py | 54 +++++++------- skfda/representation/extrapolation.py | 20 +++--- skfda/representation/grid.py | 32 ++++----- skfda/representation/interpolation.py | 24 +++---- tests/test_grid.py | 4 +- tests/test_pandas.py | 8 +-- 27 files changed, 265 insertions(+), 246 deletions(-) diff --git a/examples/plot_landmark_registration.py b/examples/plot_landmark_registration.py index 20c275b7d..1618fda67 100644 --- a/examples/plot_landmark_registration.py +++ b/examples/plot_landmark_registration.py @@ -8,9 +8,9 @@ # Author: Pablo Marcos Manchón # License: MIT -import skfda import matplotlib.pyplot as plt import numpy as np +import skfda ############################################################################### @@ -29,9 +29,8 @@ # :func:`make_multimodal_samples `, wich # in this case will be used to generate bimodal curves. # - fd = skfda.datasets.make_multimodal_samples(n_samples=4, n_modes=2, std=.002, - mode_std=.005, random_state=1) + mode_std=.005, random_state=1) fd.plot() ############################################################################### @@ -50,8 +49,8 @@ # landmarks = skfda.datasets.make_multimodal_landmarks(n_samples=4, n_modes=2, - std=.002, random_state=1 - ).squeeze() + std=.002, random_state=1 + ).squeeze() print(landmarks) @@ -76,7 +75,7 @@ # warping = skfda.preprocessing.registration.landmark_registration_warping(fd, landmarks, - location=[-0.5, 0.5]) + location=[-0.5, 0.5]) plt.figure() @@ -84,7 +83,7 @@ warping.plot() # Plot landmarks -for i in range(fd.nsamples): +for i in range(fd.n_samples): plt.scatter([-0.5, 0.5], landmarks[i]) ############################################################################### @@ -108,7 +107,8 @@ # mean position is taken. # -fd_registered = skfda.preprocessing.registration.landmark_registration(fd, landmarks) +fd_registered = skfda.preprocessing.registration.landmark_registration( + fd, landmarks) fd_registered.plot() plt.scatter(np.mean(landmarks, axis=0), [1, 1]) diff --git a/examples/plot_landmark_shift.py b/examples/plot_landmark_shift.py index a65bd383f..70c50147c 100644 --- a/examples/plot_landmark_shift.py +++ b/examples/plot_landmark_shift.py @@ -12,9 +12,10 @@ # sphinx_gallery_thumbnail_number = 2 -import skfda import matplotlib.pyplot as plt import numpy as np +import skfda + ############################################################################### # We will use an example dataset synthetically generated by @@ -24,9 +25,8 @@ # Each sample will be shifted to align their modes to a reference point using # the function :func:`landmark_shift `. # - fd = skfda.datasets.make_multimodal_samples(random_state=1) -fd.extrapolation = 'bounds' # See extrapolation for a detailed explanation. +fd.extrapolation = 'bounds' #  See extrapolation for a detailed explanation. fd.plot() @@ -52,7 +52,7 @@ landmarks = skfda.datasets.make_multimodal_landmarks(random_state=1).squeeze() plt.figure() -plt.scatter(landmarks, np.repeat(1, fd.nsamples)) +plt.scatter(landmarks, np.repeat(1, fd.n_samples)) fd.plot() ############################################################################### @@ -66,7 +66,8 @@ # landmarks at 0. # -fd_registered = skfda.preprocessing.registration.landmark_shift(fd, landmarks, location=0) +fd_registered = skfda.preprocessing.registration.landmark_shift( + fd, landmarks, location=0) plt.figure() fd_registered.plot() @@ -83,10 +84,11 @@ # Curves aligned restricting the domain fd_restricted = skfda.preprocessing.registration.landmark_shift(fd, landmarks, - restrict_domain=True) + restrict_domain=True) # Curves aligned to default point without restrict domain -fd_extrapolated = skfda.preprocessing.registration.landmark_shift(fd, landmarks) +fd_extrapolated = skfda.preprocessing.registration.landmark_shift( + fd, landmarks) plt.figure() @@ -101,7 +103,7 @@ # fd = skfda.datasets.make_multimodal_samples(n_samples=3, points_per_dim=30, - ndim_domain=2, random_state=1) + ndim_domain=2, random_state=1) fd.plot() @@ -110,7 +112,7 @@ # landmarks = skfda.datasets.make_multimodal_landmarks(n_samples=3, ndim_domain=2, - random_state=1).squeeze() + random_state=1).squeeze() print(landmarks) ############################################################################### diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 953ae3f3b..aae28c89d 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -2,15 +2,16 @@ from abc import ABC, abstractmethod -import numpy as np +import scipy.integrate from sklearn.base import BaseEstimator -from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted from sklearn.neighbors import NearestNeighbors as _NearestNeighbors -import scipy.integrate +from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted + +import numpy as np from .. import FDataGrid -from ..misc.metrics import lp_distance from ..exploratory.stats import mean as l2_mean +from ..misc.metrics import lp_distance def _to_multivariate(fdatagrid): @@ -21,11 +22,11 @@ def _to_multivariate(fdatagrid): fdatagrid (:class:`FDataGrid`): Grid to be converted to matrix Returns: - (np.array): Numpy array with size (nsamples, points), where + (np.array): Numpy array with size (n_samples, points), where points = prod([len(d) for d in fdatagrid.sample_points] """ - return fdatagrid.data_matrix.reshape(fdatagrid.nsamples, -1) + return fdatagrid.data_matrix.reshape(fdatagrid.n_samples, -1) def _from_multivariate(data_matrix, sample_points, shape, **kwargs): @@ -82,7 +83,7 @@ def _to_sklearn_metric(metric, sample_points): 1.0 """ - # Shape -> (Nsamples = 1, domain_dims...., image_dimension (-1)) + # Shape -> (n_samples = 1, domain_dims...., image_dimension (-1)) shape = [1] + [len(axis) for axis in sample_points] + [-1] def sklearn_metric(x, y, _check=False, **kwargs): @@ -495,7 +496,7 @@ def fit(self, X, y): """ - if len(X) != y.nsamples: + if len(X) != y.n_samples: raise ValueError("The response and dependent variable must " "contain the same number of samples,") diff --git a/skfda/datasets/_real_datasets.py b/skfda/datasets/_real_datasets.py index 0c659621a..1f7acd5da 100644 --- a/skfda/datasets/_real_datasets.py +++ b/skfda/datasets/_real_datasets.py @@ -460,8 +460,8 @@ def fetch_weather(return_X_y: bool = False): weather_daily = np.asarray(data["dailyAv"]) # Axes 0 and 1 must be transposed since in the downloaded dataset the - # data_matrix shape is (nfeatures, nsamples, ndim_image) while our - # data_matrix shape is (nsamples, nfeatures, ndim_image). + # data_matrix shape is (nfeatures, n_samples, ndim_image) while our + # data_matrix shape is (n_samples, nfeatures, ndim_image). temp_prec_daily = np.transpose(weather_daily[:, :, 0:2], axes=(1, 0, 2)) curves = FDataGrid(data_matrix=temp_prec_daily, diff --git a/skfda/exploratory/depth/_depth.py b/skfda/exploratory/depth/_depth.py index 263bfe987..7c71f5c59 100644 --- a/skfda/exploratory/depth/_depth.py +++ b/skfda/exploratory/depth/_depth.py @@ -146,15 +146,15 @@ def band_depth(fdatagrid, *, pointwise=False): if pointwise: return modified_band_depth(fdatagrid, pointwise) else: - n = fdatagrid.nsamples + n = fdatagrid.n_samples nchoose2 = n * (n - 1) / 2 ranks = _rank_samples(fdatagrid) axis = tuple(range(1, fdatagrid.ndim_domain + 1)) - nsamples_above = fdatagrid.nsamples - np.amax(ranks, axis=axis) - nsamples_below = np.amin(ranks, axis=axis) - 1 - depth = ((nsamples_below * nsamples_above + fdatagrid.nsamples - 1) / - nchoose2) + n_samples_above = fdatagrid.n_samples - np.amax(ranks, axis=axis) + n_samples_below = np.amin(ranks, axis=axis) - 1 + depth = ((n_samples_below * n_samples_above + fdatagrid.n_samples - 1) + / nchoose2) return depth @@ -200,24 +200,24 @@ def modified_band_depth(fdatagrid, *, pointwise=False): [ 0.83, 0.83, 0.83, 0.5 , 0.5 , 0.5 ]]) """ - n = fdatagrid.nsamples + n = fdatagrid.n_samples nchoose2 = n * (n - 1) / 2 ranks = _rank_samples(fdatagrid) - nsamples_above = fdatagrid.nsamples - ranks - nsamples_below = ranks - 1 - match = nsamples_above * nsamples_below + n_samples_above = fdatagrid.n_samples - ranks + n_samples_below = ranks - 1 + match = n_samples_above * n_samples_below axis = tuple(range(1, fdatagrid.ndim_domain + 1)) if pointwise: - depth_pointwise = (match + fdatagrid.nsamples - 1) / nchoose2 + depth_pointwise = (match + fdatagrid.n_samples - 1) / nchoose2 return depth_pointwise else: npoints_sample = reduce(lambda x, y: x * len(y), fdatagrid.sample_points, 1) proportion = match.sum(axis=axis) / npoints_sample - depth = (proportion + fdatagrid.nsamples - 1) / nchoose2 + depth = (proportion + fdatagrid.n_samples - 1) / nchoose2 return depth diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 4e95ffcc0..3e8ebc62c 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -193,7 +193,7 @@ def directional_outlyingness_stats( fdatagrid.sample_points[0], axis=1) assert mean_dir_outlyingness.shape == ( - fdatagrid.nsamples, fdatagrid.ndim_codomain) + fdatagrid.n_samples, fdatagrid.ndim_codomain) # Calculation variation directional outlyingness norm = np.square(la.norm(dir_outlyingness - @@ -202,11 +202,11 @@ def directional_outlyingness_stats( variation_dir_outlyingness = scipy.integrate.simps( weighted_norm, fdatagrid.sample_points[0], axis=1) - assert variation_dir_outlyingness.shape == (fdatagrid.nsamples,) + assert variation_dir_outlyingness.shape == (fdatagrid.n_samples,) functional_dir_outlyingness = (np.square(la.norm(mean_dir_outlyingness)) + variation_dir_outlyingness) - assert functional_dir_outlyingness.shape == (fdatagrid.nsamples,) + assert functional_dir_outlyingness.shape == (fdatagrid.n_samples,) return DirectionalOutlyingnessStats( directional_outlyingness=dir_outlyingness, @@ -222,7 +222,7 @@ class DirectionalOutlierDetector(BaseEstimator, OutlierMixin): outlier detection method is implemented as described below. First, the square robust Mahalanobis distance is calculated based on a - sample of size :math:`h \leq fdatagrid.nsamples`: + sample of size :math:`h \leq fdatagrid.n_samples`: .. math:: {RMD}^2\left( \mathbf{Y}, \mathbf{\tilde{Y}}^*_J\right) = \left( @@ -440,11 +440,11 @@ def fit_predict(self, X, y=None): dimension = X.ndim_codomain + 1 if self._force_asymptotic: self.scaling_, self.cutoff_value_ = self._parameters_asymptotic( - sample_size=X.nsamples, + sample_size=X.n_samples, dimension=dimension) else: self.scaling_, self.cutoff_value_ = self._parameters_numeric( - sample_size=X.nsamples, + sample_size=X.n_samples, dimension=dimension) rmd_2 = self.cov_.mahalanobis(self.points_) diff --git a/skfda/exploratory/outliers/_envelopes.py b/skfda/exploratory/outliers/_envelopes.py index 8b0a4dfb3..68c691618 100644 --- a/skfda/exploratory/outliers/_envelopes.py +++ b/skfda/exploratory/outliers/_envelopes.py @@ -7,7 +7,7 @@ def _compute_region(fdatagrid, indices_descending_depth, prob): indices_samples = indices_descending_depth[ - :math.ceil(fdatagrid.nsamples * prob)] + :math.ceil(fdatagrid.n_samples * prob)] return fdatagrid[indices_samples] diff --git a/skfda/exploratory/visualization/_boxplot.py b/skfda/exploratory/visualization/_boxplot.py index 1e1f9f191..ab8c43703 100644 --- a/skfda/exploratory/visualization/_boxplot.py +++ b/skfda/exploratory/visualization/_boxplot.py @@ -113,7 +113,7 @@ class Boxplot(FDataBoxplot): which the colors to represent the central regions are selected. envelopes (array, (fdatagrid.ndim_image * ncentral_regions, 2, nsample_points)): contains the region envelopes. - outliers (array, (fdatagrid.ndim_image, fdatagrid.nsamples)): + outliers (array, (fdatagrid.ndim_image, fdatagrid.n_samples)): contains the outliers. barcol (string): Color of the envelopes and vertical lines. outliercol (string): Color of the ouliers. diff --git a/skfda/exploratory/visualization/_magnitude_shape_plot.py b/skfda/exploratory/visualization/_magnitude_shape_plot.py index 38b0acc27..c0525dd22 100644 --- a/skfda/exploratory/visualization/_magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/_magnitude_shape_plot.py @@ -52,7 +52,7 @@ class MagnitudeShapePlot: in the classical boxplot. points(numpy.ndarray): 2-dimensional matrix where each row contains the points plotted in the graph. - outliers (1-D array, (fdatagrid.nsamples,)): Contains 1 or 0 to denote + outliers (1-D array, (fdatagrid.n_samples,)): Contains 1 or 0 to denote if a sample is an outlier or not, respecively. colormap(matplotlib.pyplot.LinearSegmentedColormap, optional): Colormap from which the colors of the plot are extracted. Defaults to @@ -251,7 +251,7 @@ def plot(self, ax=None): ax (axes object): axes in which the graph is plotted. """ - colors = np.zeros((self.fdatagrid.nsamples, 4)) + colors = np.zeros((self.fdatagrid.n_samples, 4)) colors[np.where(self.outliers == 1)] = self.colormap(self.outliercol) colors[np.where(self.outliers == 0)] = self.colormap(self.color) diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering_plots.py index faf32cae8..35d49da47 100644 --- a/skfda/exploratory/visualization/clustering_plots.py +++ b/skfda/exploratory/visualization/clustering_plots.py @@ -1,13 +1,17 @@ """Clustering Plots Module.""" -from ...ml.clustering.base_kmeans import FuzzyKMeans -import numpy as np -import matplotlib.pyplot as plt -from mpldatacursor import datacursor -import matplotlib.patches as mpatches +import warnings + from matplotlib.ticker import MaxNLocator +from mpldatacursor import datacursor from sklearn.exceptions import NotFittedError -import warnings + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt +import numpy as np + +from ...ml.clustering.base_kmeans import FuzzyKMeans + __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" @@ -93,12 +97,12 @@ def _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, """ if sample_colors is not None and len( - sample_colors) != fdatagrid.nsamples: + sample_colors) != fdatagrid.n_samples: raise ValueError( "sample_colors must contain a color for each sample.") if sample_labels is not None and len( - sample_labels) != fdatagrid.nsamples: + sample_labels) != fdatagrid.n_samples: raise ValueError( "sample_labels must contain a label for each sample.") @@ -144,7 +148,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, ncols(int): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - labels (numpy.ndarray, int: (nsamples, ndim_image)): 2-dimensional + labels (numpy.ndarray, int: (n_samples, ndim_image)): 2-dimensional matrix where each row contains the number of cluster cluster that observation belongs to. sample_labels (list of str): contains in order the labels of each @@ -179,7 +183,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, if sample_labels is None: sample_labels = ['$SAMPLE: {}$'.format(i) for i in - range(fdatagrid.nsamples)] + range(fdatagrid.n_samples)] if cluster_colors is None: cluster_colors = colormap( @@ -205,7 +209,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, label=cluster_labels[i])) for j in range(fdatagrid.ndim_image): - for i in range(fdatagrid.nsamples): + for i in range(fdatagrid.n_samples): ax[j].plot(fdatagrid.sample_points[0], fdatagrid.data_matrix[i, :, j], c=colors_by_cluster[i], @@ -454,14 +458,14 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, if sample_labels is None: sample_labels = ['$SAMPLE: {}$'.format(i) for i in - range(fdatagrid.nsamples)] + range(fdatagrid.n_samples)] if cluster_labels is None: cluster_labels = ['${}$'.format(i) for i in range(estimator.n_clusters)] ax.get_xaxis().set_major_locator(MaxNLocator(integer=True)) - for i in range(fdatagrid.nsamples): + for i in range(fdatagrid.n_samples): ax.plot(np.arange(estimator.n_clusters), estimator.labels_[i], label=sample_labels[i], @@ -550,7 +554,7 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, xlabels, ylabels, title = _set_labels(xlabel, ylabel, title, "Sample") if sample_labels is None: - sample_labels = np.arange(fdatagrid.nsamples) + sample_labels = np.arange(fdatagrid.n_samples) if cluster_colors is None: cluster_colors = colormap( @@ -580,14 +584,14 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, else: labels_dim = estimator.labels_ - conc = np.zeros((fdatagrid.nsamples, 1)) + conc = np.zeros((fdatagrid.n_samples, 1)) labels_dim = np.concatenate((conc, labels_dim), axis=-1) for i in range(estimator.n_clusters): - ax.bar(np.arange(fdatagrid.nsamples), + ax.bar(np.arange(fdatagrid.n_samples), labels_dim[:, i + 1], bottom=np.sum(labels_dim[:, :(i + 1)], axis=1), color=cluster_colors[i]) - ax.set_xticks(np.arange(fdatagrid.nsamples)) + ax.set_xticks(np.arange(fdatagrid.n_samples)) ax.set_xticklabels(sample_labels) ax.set_xlabel(xlabel) ax.set_ylabel(ylabel) diff --git a/skfda/misc/_lfd.py b/skfda/misc/_lfd.py index af991d5c4..80e1d2308 100644 --- a/skfda/misc/_lfd.py +++ b/skfda/misc/_lfd.py @@ -70,7 +70,7 @@ def __init__(self, order=None, weights=None, domain_range=(0, 1)): elif all(isinstance(n, FDataBasis) for n in weights): if all([_same_domain(weights[0].domain_range, - x.domain_range) and x.nsamples == 1 for x + x.domain_range) and x.n_samples == 1 for x in weights]): self.order = len(weights) - 1 self.weights = weights diff --git a/skfda/misc/_math.py b/skfda/misc/_math.py index 589034633..26212cec8 100644 --- a/skfda/misc/_math.py +++ b/skfda/misc/_math.py @@ -4,9 +4,10 @@ package. FDataBasis and FDataGrid. """ -import numpy as np import scipy.integrate +import numpy as np + __author__ = "Miguel Carbajo Berrocal" __license__ = "GPL3" @@ -188,10 +189,10 @@ def inner_product(fdatagrid, fdatagrid2): raise ValueError("Sample points for both objects must be equal") # Creates an empty matrix with the desired size to store the results. - matrix = np.empty([fdatagrid.nsamples, fdatagrid2.nsamples]) + matrix = np.empty([fdatagrid.n_samples, fdatagrid2.n_samples]) # Iterates over the different samples of both objects. - for i in range(fdatagrid.nsamples): - for j in range(fdatagrid2.nsamples): + for i in range(fdatagrid.n_samples): + for j in range(fdatagrid2.n_samples): # Calculates the inner product using Simpson's rule. matrix[i, j] = (scipy.integrate.simps( fdatagrid.data_matrix[i, ..., 0] * diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index dc4a400af..144ac65dc 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -1,12 +1,12 @@ import scipy.integrate -import numpy as np +import numpy as np -from ..representation import FData -from ..representation import FDataGrid from ..preprocessing.registration import ( normalize_warping, _normalize_scale, to_srsf, elastic_registration_warping) +from ..representation import FData +from ..representation import FDataGrid def _cast_to_grid(fdata1, fdata2, eval_points=None, _check=True, **kwargs): @@ -109,7 +109,6 @@ def vectorial_norm(fdatagrid, p=2): """ - if p == 'inf': p = np.inf @@ -194,13 +193,12 @@ def pairwise(fdata1, fdata2): fdata1, fdata2 = _cast_to_grid(fdata1, fdata2, **kwargs) - # Creates an empty matrix with the desired size to store the results. - matrix = np.empty((fdata1.nsamples, fdata2.nsamples)) + matrix = np.empty((fdata1.n_samples, fdata2.n_samples)) # Iterates over the different samples of both objects. - for i in range(fdata1.nsamples): - for j in range(fdata2.nsamples): + for i in range(fdata1.n_samples): + for j in range(fdata2.n_samples): matrix[i, j] = distance(fdata1[i], fdata2[j], _check=False, **kwargs) # Computes the metric between all piars of x and y. @@ -512,6 +510,7 @@ def amplitude_distance(fdata1, fdata2, *, lam=0., eval_points=None, return distance + def phase_distance(fdata1, fdata2, *, lam=0., eval_points=None, _check=True, **kwargs): r"""Compute the phase distance between two functional objects. diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index 1b070dc18..5faf71578 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -1,14 +1,18 @@ """K-Means Algorithms Module.""" -import numpy as np -from ...representation.grid import FDataGrid -from ...misc.metrics import pairwise_distance, lp_distance from abc import abstractmethod +import warnings + from sklearn.base import BaseEstimator, ClusterMixin, TransformerMixin from sklearn.exceptions import NotFittedError -import warnings from sklearn.utils import check_random_state +import numpy as np + +from ...misc.metrics import pairwise_distance, lp_distance +from ...representation.grid import FDataGrid + + __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" @@ -35,8 +39,8 @@ def __init__(self, n_clusters, init, metric, n_init, max_iter, tol, fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and - returns a matrix with shape (fdatagrid1.nsamples, - fdatagrid2.nsamples). Defaults to *pairwise_distance(lp_distance)*. + returns a matrix with shape (fdatagrid1.n_samples, + fdatagrid2.n_samples). Defaults to *pairwise_distance(lp_distance)*. n_init (int, optional): Number of time the k-means algorithm will be run with different centroid seeds. The final results will be the best output of n_init consecutive runs in terms of inertia. @@ -72,7 +76,7 @@ def _generic_clustering_checks(self, fdatagrid): raise NotImplementedError( "Only support 1 dimension on the domain.") - if fdatagrid.nsamples < 2: + if fdatagrid.n_samples < 2: raise ValueError( "The number of observations must be greater than 1.") @@ -121,7 +125,7 @@ def _init_centroids(self, fdatagrid, random_state): """ comparison = True while comparison: - indices = random_state.permutation(fdatagrid.nsamples)[ + indices = random_state.permutation(fdatagrid.n_samples)[ :self.n_clusters] centers = fdatagrid.data_matrix[indices] unique_centers = np.unique(centers, axis=0) @@ -208,7 +212,7 @@ def transform(self, X): convention. Returns: - distances_to_centers (numpy.ndarray: (nsamples, n_clusters)): + distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): distances of each sample to each cluster. """ self._check_is_fitted() @@ -226,7 +230,7 @@ def fit_transform(self, X, y=None, sample_weight=None): convention. Returns: - distances_to_centers (numpy.ndarray: (nsamples, n_clusters)): + distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): distances of each sample to each cluster. """ self.fit(X) @@ -313,7 +317,7 @@ class KMeans(BaseKMeans): be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and returns - a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). + a matrix with shape (fdatagrid1.n_samples, fdatagrid2.n_samples). Defaults to *pairwise_distance(lp_distance)*. n_init (int, optional): Number of time the k-means algorithm will be run with different centroid seeds. The final results will be the @@ -329,7 +333,7 @@ class KMeans(BaseKMeans): See :term:`Glossary `. Attributes: - labels_ (numpy.ndarray: (nsamples, ndim_image)): 2-dimensional matrix + labels_ (numpy.ndarray: (n_samples, ndim_image)): 2-dimensional matrix in which each row contains the cluster that observation belongs to. cluster_centers_ (FDataGrid object): data_matrix of shape (n_clusters, ncol, ndim_image) and contains the centroids for @@ -373,8 +377,8 @@ def __init__(self, n_clusters=2, init=None, fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and - returns a matrix with shape (fdatagrid1.nsamples, - fdatagrid2.nsamples). + returns a matrix with shape (fdatagrid1.n_samples, + fdatagrid2.n_samples). Defaults to *pairwise_distance(lp_distance)*. n_init (int, optional): Number of time the k-means algorithm will be run with different centroid seeds. The final results will @@ -408,14 +412,14 @@ def _kmeans_implementation(self, fdatagrid, random_state): Returns: (tuple): tuple containing: - clustering_values (numpy.ndarray: (nsamples,)): 1-dimensional + clustering_values (numpy.ndarray: (n_samples,)): 1-dimensional array where each row contains the cluster that observation belongs to. centers (numpy.ndarray: (n_clusters, ncol, ndim_image)): Contains the centroids for each cluster. - distances_to_centers (numpy.ndarray: (nsamples, n_clusters)): + distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): distances of each sample to each cluster. repetitions(int): number of iterations the algorithm was run. @@ -460,12 +464,13 @@ def fit(self, X, y=None, sample_weight=None): fdatagrid = super()._generic_clustering_checks(fdatagrid=X) clustering_values = np.empty( - (self.n_init, fdatagrid.nsamples)).astype(int) + (self.n_init, fdatagrid.n_samples)).astype(int) centers = np.empty((self.n_init, self.n_clusters, fdatagrid.ncol, fdatagrid.ndim_image)) distances_to_centers = np.empty( - (self.n_init, fdatagrid.nsamples, self.n_clusters)) - distances_to_their_center = np.empty((self.n_init, fdatagrid.nsamples)) + (self.n_init, fdatagrid.n_samples, self.n_clusters)) + distances_to_their_center = np.empty( + (self.n_init, fdatagrid.n_samples)) n_iter = np.empty((self.n_init)) for j in range(self.n_init): @@ -474,7 +479,7 @@ def fit(self, X, y=None, sample_weight=None): self._kmeans_implementation(fdatagrid=fdatagrid, random_state=random_state)) distances_to_their_center[j, :] = distances_to_centers[ - j, np.arange(fdatagrid.nsamples), + j, np.arange(fdatagrid.n_samples), clustering_values[j, :]] inertia = np.sum(distances_to_their_center ** 2, axis=1) @@ -558,7 +563,7 @@ class FuzzyKMeans(BaseKMeans): be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and returns - a matrix with shape (fdatagrid1.nsamples, fdatagrid2.nsamples). + a matrix with shape (fdatagrid1.n_samples, fdatagrid2.n_samples). Defaults to *pairwise_distance(lp_distance)*. n_init (int, optional): Number of time the k-means algorithm will be run with different centroid seeds. The final results will be the @@ -578,7 +583,7 @@ class FuzzyKMeans(BaseKMeans): returned in the fuzzy algorithm. Defaults to 3. Attributes: - labels_ (numpy.ndarray: (nsamples, ndim_image)): 2-dimensional matrix + labels_ (numpy.ndarray: (n_samples, ndim_image)): 2-dimensional matrix in which each row contains the cluster that observation belongs to. cluster_centers_ (FDataGrid object): data_matrix of shape (n_clusters, ncol, ndim_image) and contains the centroids for @@ -623,8 +628,8 @@ def __init__(self, n_clusters=2, init=None, fdatagrid.ndim_image). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and - returns a matrix with shape (fdatagrid1.nsamples, - fdatagrid2.nsamples). + returns a matrix with shape (fdatagrid1.n_samples, + fdatagrid2.n_samples). Defaults to *pairwise_distance(lp_distance)*. n_init (int, optional): Number of time the k-means algorithm will be run with different centroid seeds. The final results will be @@ -663,14 +668,14 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): Returns: (tuple): tuple containing: - membership values (numpy.ndarray: (nsamples, n_clusters)): + membership values (numpy.ndarray: (n_samples, n_clusters)): 2-dimensional matrix where each row contains the membership value that observation has to each cluster. centers (numpy.ndarray: (n_clusters, ncol, ndim_image)): Contains the centroids for each cluster. - distances_to_centers (numpy.ndarray: (nsamples, n_clusters)): + distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): distances of each sample to each cluster. repetitions(int): number of iterations the algorithm was run. @@ -679,8 +684,8 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): repetitions = 0 centers_old = np.zeros( (self.n_clusters, fdatagrid.ncol, fdatagrid.ndim_image)) - U = np.empty((fdatagrid.nsamples, self.n_clusters)) - distances_to_centers = np.empty((fdatagrid.nsamples, self.n_clusters)) + U = np.empty((fdatagrid.n_samples, self.n_clusters)) + distances_to_centers = np.empty((fdatagrid.n_samples, self.n_clusters)) if self.init is None: centers = self._init_centroids(fdatagrid, random_state) @@ -698,7 +703,7 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): distances_to_centers_raised = (distances_to_centers ** ( 2 / (self.fuzzifier - 1))) - for i in range(fdatagrid.nsamples): + for i in range(fdatagrid.n_samples): comparison = (fdatagrid.data_matrix[i] == centers).all( axis=tuple(np.arange(fdatagrid.data_matrix.ndim)[1:])) if comparison.sum() >= 1: @@ -742,13 +747,14 @@ def fit(self, X, y=None, sample_weight=None): "obtain a rational result.") membership_values = np.empty( - (self.n_init, fdatagrid.nsamples, self.n_clusters)) + (self.n_init, fdatagrid.n_samples, self.n_clusters)) centers = np.empty( (self.n_init, self.n_clusters, fdatagrid.ncol, fdatagrid.ndim_image)) distances_to_centers = np.empty( - (self.n_init, fdatagrid.nsamples, self.n_clusters)) - distances_to_their_center = np.empty((self.n_init, fdatagrid.nsamples)) + (self.n_init, fdatagrid.n_samples, self.n_clusters)) + distances_to_their_center = np.empty( + (self.n_init, fdatagrid.n_samples)) n_iter = np.empty((self.n_init)) for j in range(self.n_init): @@ -757,7 +763,7 @@ def fit(self, X, y=None, sample_weight=None): self._fuzzy_kmeans_implementation(fdatagrid=fdatagrid, random_state=random_state)) distances_to_their_center[j, :] = distances_to_centers[ - j, np.arange(fdatagrid.nsamples), + j, np.arange(fdatagrid.n_samples), np.argmax(membership_values[j, :, :], axis=-1)] inertia = np.sum(distances_to_their_center ** 2, axis=1) diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 1698e3710..21a40f47e 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -1,9 +1,8 @@ from sklearn.base import BaseEstimator, RegressorMixin -from skfda.representation.basis import FDataBasis, Constant, Basis, FData +from sklearn.utils.validation import check_is_fitted import numpy as np - -from sklearn.utils.validation import check_is_fitted +from skfda.representation.basis import FDataBasis, Constant, Basis, FData class LinearScalarRegression(BaseEstimator, RegressorMixin): @@ -16,9 +15,9 @@ def fit(self, X, y=None, sample_weight=None): y, X, weights = self._argcheck(y, X, sample_weight) nbeta = len(self.beta_basis) - nsamples = X[0].nsamples + n_samples = X[0].n_samples - y = np.asarray(y).reshape((nsamples, 1)) + y = np.asarray(y).reshape((n_samples, 1)) for j in range(nbeta): xcoef = X[j].coefficients @@ -41,7 +40,8 @@ def fit(self, X, y=None, sample_weight=None): idx = 0 for j in range(0, nbeta): - self.beta_basis[j] = FDataBasis(self.beta_basis[j], betacoefs[idx:idx+self.beta_basis[j].nbasis].T) + self.beta_basis[j] = FDataBasis( + self.beta_basis[j], betacoefs[idx:idx + self.beta_basis[j].nbasis].T) idx = idx + self.beta_basis[j].nbasis self.beta_ = self.beta_basis @@ -50,9 +50,9 @@ def fit(self, X, y=None, sample_weight=None): def predict(self, X): check_is_fitted(self, "beta_") return [sum(self.beta[i].inner_product(X[i][j])[0, 0] for i in - range(len(self.beta))) for j in range(X[0].nsamples)] + range(len(self.beta))) for j in range(X[0].n_samples)] - def _argcheck(self, y, x, weights = None): + def _argcheck(self, y, x, weights=None): """Do some checks to types and shapes""" if all(not isinstance(i, FData) for i in x): raise ValueError("All the dependent variable are scalar.") @@ -75,7 +75,7 @@ def _argcheck(self, y, x, weights = None): xjcoefs = np.array(x[j]).reshape((-1, 1)) x[j] = FDataBasis(Constant(domain_range), xjcoefs) - if any(ylen != xfd.nsamples for xfd in x): + if any(ylen != xfd.n_samples for xfd in x): raise ValueError("The number of samples on independent and " "dependent variables should be the same") diff --git a/skfda/preprocessing/dim_reduction/projection/_fpca.py b/skfda/preprocessing/dim_reduction/projection/_fpca.py index 1010ecfcb..f966cce17 100644 --- a/skfda/preprocessing/dim_reduction/projection/_fpca.py +++ b/skfda/preprocessing/dim_reduction/projection/_fpca.py @@ -1,9 +1,10 @@ """Functional principal component analysis. """ -from ....exploratory.stats import mean import numpy as np +from ....exploratory.stats import mean + def fpca(fdatagrid, n=2): """Compute Functional Principal Components Analysis. @@ -26,7 +27,7 @@ def fpca(fdatagrid, n=2): # singular value decomposition u, s, v = np.linalg.svd(fdatagrid.data_matrix) principal_directions = v.T # obtain the eigenvectors matrix - eigenvalues = (np.diag(s) ** 2) / (fdatagrid.nsamples - 1) + eigenvalues = (np.diag(s) ** 2) / (fdatagrid.n_samples - 1) scores = u @ s # functional principal scores return scores, principal_directions, eigenvalues diff --git a/skfda/preprocessing/registration/_elastic.py b/skfda/preprocessing/registration/_elastic.py index 7209a1ad7..c363b2f2e 100644 --- a/skfda/preprocessing/registration/_elastic.py +++ b/skfda/preprocessing/registration/_elastic.py @@ -1,12 +1,15 @@ -import numpy as np import scipy.integrate + +import numpy as np import optimum_reparam + from . import invert_warping +from ... import FDataGrid from ._registration_utils import _normalize_scale -from ... import FDataGrid + from...representation.interpolation import SplineInterpolator @@ -138,7 +141,7 @@ def from_srsf(fdatagrid, initial=None, *, eval_points=None): if initial is not None: initial = np.atleast_1d(initial) - initial = initial.reshape(fdatagrid.nsamples, 1, fdatagrid.ndim_image) + initial = initial.reshape(fdatagrid.n_samples, 1, fdatagrid.ndim_image) initial = np.repeat(initial, len(eval_points), axis=1) f_data_matrix += initial @@ -264,7 +267,7 @@ def elastic_registration_warping(fdatagrid, template=None, *, lam=0., template = elastic_mean(fdatagrid, lam=lam, eval_points=eval_points, **kwargs) - elif ((template.nsamples != 1 and template.nsamples != fdatagrid.nsamples) + elif ((template.n_samples != 1 and template.n_samples != fdatagrid.n_samples) or template.ndim_domain != 1 or template.ndim_image != 1): raise ValueError("The template should contain one sample to align all" @@ -450,7 +453,7 @@ def warping_mean(warping, *, iter=20, tol=1e-5, step_size=1., eval_points=None, n_points = mu.shape[0] - sine = np.empty((warping.nsamples, 1)) + sine = np.empty((warping.n_samples, 1)) for _ in range(iter): # Dot product diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index ef27eb1e0..da6791e40 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -68,9 +68,9 @@ def landmark_shift_deltas(fd, landmarks, location=None): """ - if len(landmarks) != fd.nsamples: + if len(landmarks) != fd.n_samples: raise ValueError(f"landmark list ({len(landmarks)}) must have the same" - f" length than the number of samples ({fd.nsamples})") + f" length than the number of samples ({fd.n_samples})") landmarks = np.atleast_1d(landmarks) @@ -222,15 +222,15 @@ def landmark_registration_warping(fd, landmarks, *, location=None, raise NotImplementedError("Method only implemented for objects with" "domain dimension up to 1.") - if len(landmarks) != fd.nsamples: + if len(landmarks) != fd.n_samples: raise ValueError("The number of list of landmarks should be equal to " "the number of samples") - landmarks = np.asarray(landmarks).reshape((fd.nsamples, -1)) + landmarks = np.asarray(landmarks).reshape((fd.n_samples, -1)) n_landmarks = landmarks.shape[-1] - data_matrix = np.empty((fd.nsamples, n_landmarks + 2)) + data_matrix = np.empty((fd.n_samples, n_landmarks + 2)) data_matrix[:, 0] = fd.domain_range[0][0] data_matrix[:, -1] = fd.domain_range[0][1] diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index 5cca24805..07d002f1a 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -4,10 +4,12 @@ """ import collections -import numpy as np import scipy.integrate from scipy.interpolate import PchipInterpolator +import numpy as np + + __author__ = "Pablo Marcos Manchón" __email__ = "pablo.marcosm@estudiante.uam.es" @@ -132,18 +134,18 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, if registered_fdata.ndim_domain != 1 or registered_fdata.ndim_image != 1: raise NotImplementedError - if original_fdata.nsamples != registered_fdata.nsamples: + if original_fdata.n_samples != registered_fdata.n_samples: raise ValueError(f"the registered and unregistered curves must have " f"the same number of samples " - f"({registered_fdata.nsamples})!= " - f"({original_fdata.nsamples})") + f"({registered_fdata.n_samples})!= " + f"({original_fdata.n_samples})") - if warping_function is not None and (warping_function.nsamples - != original_fdata.nsamples): + if warping_function is not None and (warping_function.n_samples + != original_fdata.n_samples): raise ValueError(f"the registered curves and the warping functions " f"must have the same number of samples " - f"({registered_fdata.nsamples})" - f"!=({warping_function.nsamples})") + f"({registered_fdata.n_samples})" + f"!=({warping_function.n_samples})") # Creates the mesh to discretize the functions if eval_points is None: @@ -274,9 +276,9 @@ def invert_warping(fdatagrid, *, eval_points=None): y = fdatagrid(eval_points, keepdims=False) - data_matrix = np.empty((fdatagrid.nsamples, len(eval_points))) + data_matrix = np.empty((fdatagrid.n_samples, len(eval_points))) - for i in range(fdatagrid.nsamples): + for i in range(fdatagrid.n_samples): data_matrix[i] = PchipInterpolator(y[i], eval_points)(eval_points) return fdatagrid.copy(data_matrix=data_matrix, sample_points=eval_points) diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 447f0f249..0eef2098e 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -4,11 +4,13 @@ functional data using shifts, in basis as well in discretized form. """ -import numpy as np import scipy.integrate +import numpy as np + from ..._utils import constants + __author__ = "Pablo Marcos Manchón" __email__ = "pablo.marcosm@estudiante.uam.es" @@ -104,12 +106,12 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, domain_range = fd.domain_range[0] if initial is None: - delta = np.zeros(fd.nsamples) + delta = np.zeros(fd.n_samples) - elif len(initial) != fd.nsamples: + elif len(initial) != fd.n_samples: raise ValueError(f"the initial shift ({len(initial)}) must have the " f"same length than the number of samples " - f"({fd.nsamples})") + f"({fd.n_samples})") else: delta = np.asarray(initial) @@ -129,7 +131,7 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, eval_points = np.asarray(eval_points) # Auxiliar arrays to avoid multiple memory allocations - delta_aux = np.empty(fd.nsamples) + delta_aux = np.empty(fd.n_samples) tfine_aux = np.empty(nfine) # Computes the derivate of originals curves in the mesh points @@ -150,7 +152,7 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, tfine_aux_tmp = tfine_aux domain = np.empty(nfine, dtype=np.dtype(bool)) - ones = np.ones(fd.nsamples) + ones = np.ones(fd.n_samples) eval_points_rep = np.outer(ones, eval_points) # Newton-Rhapson iteration diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index a00cfaa7a..42b5582c5 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -13,16 +13,16 @@ import matplotlib.patches as mpatches import matplotlib.pyplot as plt import numpy as np -from .extrapolation import _parse_extrapolation from .._utils import _coordinate_list, _list_of_arrays, constants +from .extrapolation import _parse_extrapolation class FData(ABC, pandas.api.extensions.ExtensionArray): """Defines the structure of a functional data object. Attributes: - nsamples (int): Number of samples. + n_samples (int): Number of samples. ndim_domain (int): Dimension of the domain. ndim_image (int): Dimension of the image. extrapolation (Extrapolation): Default extrapolation mode. @@ -65,7 +65,7 @@ def axes_labels(self, labels): @property @abstractmethod - def nsamples(self): + def n_samples(self): """Return the number of samples. Returns: @@ -169,7 +169,7 @@ def _reshape_eval_points(self, eval_points, evaluation_aligned): (np.ndarray): Numpy array with the eval_points, if evaluation_aligned is True with shape `number of evaluation points` x `ndim_domain`. If the points are not aligned the shape of the - points will be `nsamples` x `number of evaluation points` + points will be `n_samples` x `number of evaluation points` x `ndim_domain`. """ @@ -188,10 +188,10 @@ def _reshape_eval_points(self, eval_points, evaluation_aligned): else: # Different eval_points for each sample - if eval_points.ndim < 2 or eval_points.shape[0] != self.nsamples: + if eval_points.ndim < 2 or eval_points.shape[0] != self.n_samples: raise ValueError(f"eval_points should be a list " - f"of length {self.nsamples} with the " + f"of length {self.n_samples} with the " f"evaluation points for each sample.") eval_points = eval_points.reshape((eval_points.shape[0], @@ -205,7 +205,7 @@ def _extrapolation_index(self, eval_points): Args: eval_points (np.ndarray): Array with shape `n_eval_points` x - `ndim_domain` with the evaluation points, or shape ´nsamples´ x + `ndim_domain` with the evaluation points, or shape ´n_samples´ x `n_eval_points` x `ndim_domain` with different evaluation points for each sample. @@ -261,7 +261,7 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, in a different grid. keepdims (bool, optional): If the image dimension is equal to 1 and keepdims is True the return matrix has shape - nsamples x eval_points x 1 else nsamples x eval_points. + n_samples x eval_points x 1 else n_samples x eval_points. By default is used the value given during the instance of the object. @@ -300,7 +300,7 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, aligned_evaluation=False) else: - if len(axes) != self.nsamples: + if len(axes) != self.n_samples: raise ValueError("Should be provided a list of axis per " "sample") elif len(axes[0]) != self.ndim_domain: @@ -308,18 +308,18 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, f"({self.ndim_domain}) != {len(axes[0])}") lengths = [len(ax) for ax in axes[0]] - eval_points = np.empty((self.nsamples, + eval_points = np.empty((self.n_samples, np.prod(lengths), self.ndim_domain)) - for i in range(self.nsamples): + for i in range(self.n_samples): eval_points[i] = _coordinate_list(axes[i]) res = self.evaluate(eval_points, derivative=derivative, extrapolation=extrapolation, keepdims=True, aligned_evaluation=False) - shape = [self.nsamples] + lengths + shape = [self.n_samples] + lengths if keepdims is None: keepdims = self.keepdims @@ -348,10 +348,10 @@ def _join_evaluation(self, index_matrix, index_ext, index_ev, Returns: (ndarray): Matrix with the points evaluated with shape - `nsamples` x `number of points evaluated` x `ndim_image`. + `n_samples` x `number of points evaluated` x `ndim_image`. """ - res = np.empty((self.nsamples, index_matrix.shape[-1], + res = np.empty((self.n_samples, index_matrix.shape[-1], self.ndim_image)) # Case aligned evaluation @@ -433,12 +433,12 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, spanned by the input arrays, or at points specified by the input arrays. If true the eval_points should be a list of size ndim_domain with the corresponding times for each axis. The - return matrix has shape nsamples x len(t1) x len(t2) x ... x + return matrix has shape n_samples x len(t1) x len(t2) x ... x len(t_ndim_domain) x ndim_image. If the domain dimension is 1 the parameter has no efect. Defaults to False. keepdims (bool, optional): If the image dimension is equal to 1 and keepdims is True the return matrix has shape - nsamples x eval_points x 1 else nsamples x eval_points. + n_samples x eval_points x 1 else n_samples x eval_points. By default is used the value given during the instance of the object. @@ -552,12 +552,12 @@ def __call__(self, eval_points, *, derivative=0, extrapolation=None, spanned by the input arrays, or at points specified by the input arrays. If true the eval_points should be a list of size ndim_domain with the corresponding times for each axis. The - return matrix has shape nsamples x len(t1) x len(t2) x ... x + return matrix has shape n_samples x len(t1) x len(t2) x ... x len(t_ndim_domain) x ndim_image. If the domain dimension is 1 the parameter has no efect. Defaults to False. keepdims (bool, optional): If the image dimension is equal to 1 and keepdims is True the return matrix has shape - nsamples x eval_points x 1 else nsamples x eval_points. + n_samples x eval_points x 1 else n_samples x eval_points. By default is used the value given during the instance of the object. @@ -965,15 +965,15 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, else: if 'color' in kwargs: - sample_colors = self.nsamples * [kwargs.get("color")] + sample_colors = self.n_samples * [kwargs.get("color")] kwargs.pop('color') elif 'c' in kwargs: - sample_colors = self.nsamples * [kwargs.get("color")] + sample_colors = self.n_samples * [kwargs.get("color")] kwargs.pop('c') else: - sample_colors = np.empty((self.nsamples,)).astype(str) + sample_colors = np.empty((self.n_samples,)).astype(str) next_color = True if self.ndim_domain == 1: @@ -986,7 +986,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, mat = self(eval_points, derivative=derivative, keepdims=True) for i in range(self.ndim_image): - for j in range(self.nsamples): + for j in range(self.n_samples): if sample_labels is None and next_color: sample_colors[j] = ax[i]._get_lines.get_next_color() ax[i].plot(eval_points, mat[j, ..., i].T, @@ -1013,7 +1013,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, X, Y = np.meshgrid(x, y, indexing='ij') for i in range(self.ndim_image): - for j in range(self.nsamples): + for j in range(self.n_samples): if sample_labels is None and next_color: sample_colors[j] = ax[i]._get_lines.get_next_color() ax[i].plot_surface(X, Y, Z[j, ..., i], @@ -1175,13 +1175,13 @@ def __rtruediv__(self, other): def __iter__(self): """Iterate over the samples""" - for i in range(self.nsamples): + for i in range(self.n_samples): yield self[i] def __len__(self): """Returns the number of samples of the FData object.""" - return self.nsamples + return self.n_samples ##################################################################### # Numpy methods @@ -1239,7 +1239,7 @@ def isna(self): Returns: na_values (np.ndarray): Array full of False values. """ - return np.zeros(self.nsamples, dtype=bool) + return np.zeros(self.n_samples, dtype=bool) def take(self, indices, allow_fill=False, fill_value=None, axis=0): """Take elements from an array. diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 46873bf38..1f99d2689 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1713,7 +1713,7 @@ def from_data(cls, data_matrix, sample_points, basis, return smoother.fit_transform(fd) @property - def nsamples(self): + def n_samples(self): """Return number of samples.""" return self.coefficients.shape[0] @@ -1763,7 +1763,7 @@ def _evaluate(self, eval_points, *, derivative=0): Args: eval_points (array_like): List of points where the functions are - evaluated. If a matrix of shape `nsamples` x eval_points is + evaluated. If a matrix of shape `n_samples` x eval_points is given each sample is evaluated at the values in the corresponding row. derivative (int, optional): Order of the derivative. Defaults to 0. @@ -1782,7 +1782,7 @@ def _evaluate(self, eval_points, *, derivative=0): res = np.tensordot(self.coefficients, basis_values, axes=(1, 0)) - return res.reshape((self.nsamples, len(eval_points), 1)) + return res.reshape((self.n_samples, len(eval_points), 1)) def _evaluate_composed(self, eval_points, *, derivative=0): r"""Evaluate the object or its derivatives at a list of values with a @@ -1796,7 +1796,7 @@ def _evaluate_composed(self, eval_points, *, derivative=0): as :func:`evaluate`. Args: - eval_points (numpy.ndarray): Matrix of size `nsamples`x n_points + eval_points (numpy.ndarray): Matrix of size `n_samples`x n_points derivative (int, optional): Order of the derivative. Defaults to 0. extrapolation (str or Extrapolation, optional): Controls the extrapolation mode for elements outside the domain range. @@ -1810,17 +1810,17 @@ def _evaluate_composed(self, eval_points, *, derivative=0): eval_points = eval_points[..., 0] - res_matrix = np.empty((self.nsamples, eval_points.shape[1])) + res_matrix = np.empty((self.n_samples, eval_points.shape[1])) _matrix = np.empty((eval_points.shape[1], self.nbasis)) - for i in range(self.nsamples): + for i in range(self.n_samples): basis_values = self.basis.evaluate(eval_points[i], derivative).T np.multiply(basis_values, self.coefficients[i], out=_matrix) np.sum(_matrix, axis=1, out=res_matrix[i]) - return res_matrix.reshape((self.nsamples, eval_points.shape[1], 1)) + return res_matrix.reshape((self.n_samples, eval_points.shape[1], 1)) def shift(self, shifts, *, restrict_domain=False, extrapolation=None, eval_points=None, **kwargs): @@ -1870,10 +1870,10 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, eval_points + shifts, _basis, **kwargs) - elif len(shifts) != self.nsamples: + elif len(shifts) != self.n_samples: raise ValueError(f"shifts vector ({len(shifts)}) must have the " f"same length than the number of samples " - f"({self.nsamples})") + f"({self.n_samples})") if restrict_domain: a = domain_range[0] - min(np.min(shifts), 0) @@ -1885,7 +1885,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, else: domain = domain_range - points_shifted = np.outer(np.ones(self.nsamples), + points_shifted = np.outer(np.ones(self.n_samples), eval_points) points_shifted += np.atleast_2d(shifts).T @@ -1939,16 +1939,14 @@ def mean(self, weights=None): """ if weights is not None: - return self.copy(coefficients= - np.average(self.coefficients, - weights=weights, - axis=0 - )[np.newaxis,...] + return self.copy(coefficients=np.average(self.coefficients, + weights=weights, + axis=0 + )[np.newaxis, ...] ) return self.copy(coefficients=np.mean(self.coefficients, axis=0)) - def gmean(self, eval_points=None): """Compute the geometric mean of the functional data object. @@ -2074,7 +2072,7 @@ def to_basis(self, basis, eval_points=None, **kwargs): def to_list(self): """Splits FDataBasis samples into a list""" - return [self[i] for i in range(self.nsamples)] + return [self[i] for i in range(self.n_samples)] def copy(self, *, basis=None, coefficients=None, dataset_label=None, axes_labels=None, extrapolation=None, keepdims=None): @@ -2134,14 +2132,14 @@ def times(self, other): evalarg = np.linspace(left, right, neval) first = self.copy(coefficients=(np.repeat(self.coefficients, - other.nsamples, axis=0) - if (self.nsamples == 1 and - other.nsamples > 1) + other.n_samples, axis=0) + if (self.n_samples == 1 and + other.n_samples > 1) else self.coefficients.copy())) second = other.copy(coefficients=(np.repeat(other.coefficients, - self.nsamples, axis=0) - if (other.nsamples == 1 and - self.nsamples > 1) + self.n_samples, axis=0) + if (other.n_samples == 1 and + self.n_samples > 1) else other.coefficients.copy())) fdarray = first.evaluate(evalarg) * second.evaluate(evalarg) @@ -2149,7 +2147,7 @@ def times(self, other): return FDataBasis.from_data(fdarray, evalarg, basisobj) if isinstance(other, int): - other = [other for _ in range(self.nsamples)] + other = [other for _ in range(self.n_samples)] coefs = np.transpose(np.atleast_2d(other)) return self.copy(coefficients=self.coefficients * coefs) @@ -2207,7 +2205,7 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, if weights is not None: other = other.times(weights) - if self.nsamples * other.nsamples > self.nbasis * other.nbasis: + if self.n_samples * other.n_samples > self.nbasis * other.nbasis: return (self.coefficients @ self.basis._inner_matrix(other.basis) @ other.coefficients.T) @@ -2216,11 +2214,11 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, def _inner_product_integrate(self, other, lfd_self, lfd_other): - matrix = np.empty((self.nsamples, other.nsamples)) + matrix = np.empty((self.n_samples, other.n_samples)) (left, right) = self.domain_range[0] - for i in range(self.nsamples): - for j in range(other.nsamples): + for i in range(self.n_samples): + for j in range(other.n_samples): fd = self[i].times(other[j]) matrix[i, j] = scipy.integrate.quad( lambda x: fd.evaluate([x])[0], left, right)[0] diff --git a/skfda/representation/extrapolation.py b/skfda/representation/extrapolation.py index 12f82ac2c..21f14e3c4 100644 --- a/skfda/representation/extrapolation.py +++ b/skfda/representation/extrapolation.py @@ -4,10 +4,10 @@ """ -from .evaluator import EvaluatorConstructor, Evaluator, GenericEvaluator - import numpy as np +from .evaluator import EvaluatorConstructor, Evaluator, GenericEvaluator + class PeriodicExtrapolation(EvaluatorConstructor): """Extends the domain range periodically. @@ -51,13 +51,13 @@ def _periodic_evaluation(fdata, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `nsamples` x `n_eval_points` + `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` x `ndim_image`. derivate (numeric, optional): Order of derivative to be evaluated. Returns: (numpy.ndarray): numpy array with the evaluation of the points in - a matrix with shape `nsamples` x `n_eval_points`x `ndim_image`. + a matrix with shape `n_samples` x `n_eval_points`x `ndim_image`. """ domain_range = np.asarray(fdata.domain_range) @@ -117,13 +117,13 @@ def _boundary_evaluation(fdata, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `nsamples` x `n_eval_points` + `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` x `ndim_image`. derivate (numeric, optional): Order of derivative to be evaluated. Returns: (numpy.ndarray): numpy array with the evaluation of the points in - a matrix with shape `nsamples` x `n_eval_points`x `ndim_image`. + a matrix with shape `n_samples` x `n_eval_points`x `ndim_image`. """ domain_range = fdata.domain_range @@ -190,7 +190,7 @@ def _exception_evaluation(fdata, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `nsamples` x `n_eval_points` + `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` x `ndim_image`. derivate (numeric, optional): Order of derivative to be evaluated. @@ -263,7 +263,7 @@ def __init__(self, fdata, fill_value): self.fdata = fdata def _fill(self, eval_points): - shape = (self.fdata.nsamples, eval_points.shape[-2], + shape = (self.fdata.n_samples, eval_points.shape[-2], self.fdata.ndim_image) return np.full(shape, self.fill_value) @@ -275,13 +275,13 @@ def evaluate(self, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `nsamples` x `n_eval_points` + `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` x `ndim_image`. derivate (numeric, optional): Order of derivative to be evaluated. Returns: (numpy.ndarray): numpy array with the evaluation of the points in - a matrix with shape `nsamples` x `n_eval_points`x `ndim_image`. + a matrix with shape `n_samples` x `n_eval_points`x `ndim_image`. """ return self._fill(eval_points) diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index ce6bc3246..035f6e047 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -303,7 +303,7 @@ def coordinates(self): return FDataGrid._CoordinateIterator(self) @property - def nsamples(self): + def n_samples(self): """Return number of rows of the data_matrix. Also the number of samples. Returns: @@ -488,7 +488,7 @@ def derivative(self, order=1): sample_points = self.sample_points[0] for _ in range(order): mdata = [] - for i in range(self.nsamples): + for i in range(self.n_samples): arr = (np.diff(data_matrix[i]) / (sample_points[1:] - sample_points[:-1])) @@ -525,8 +525,8 @@ def mean(self, weights=None): if weights is not None: return self.copy(data_matrix=np.average( - self.data_matrix, weights=weights, axis=0)[np.newaxis,...] - ) + self.data_matrix, weights=weights, axis=0)[np.newaxis, ...] + ) return self.copy(data_matrix=self.data_matrix.mean(axis=0, keepdims=True)) @@ -778,10 +778,10 @@ def concatenate(self, *others, as_coordinates=False): raise ValueError("All the FDataGrids must be sampled in the same " "sample points.") - elif any([self.nsamples != other.nsamples for other in others]): + elif any([self.n_samples != other.n_samples for other in others]): raise ValueError(f"All the FDataGrids must contain the same " - f"number of samples {self.nsamples} to " + f"number of samples {self.n_samples} to " f"concatenate as a new coordinate.") data = [self.data_matrix] + [other.data_matrix for other in others] @@ -824,7 +824,7 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): if self.ndim_domain == 1: for i in range(self.ndim_image): - for j in range(self.nsamples): + for j in range(self.n_samples): ax[i].scatter(self.sample_points[0], self.data_matrix[j, :, i].T, **kwargs) else: @@ -832,7 +832,7 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): Y = self.sample_points[1] X, Y = np.meshgrid(X, Y) for i in range(self.ndim_image): - for j in range(self.nsamples): + for j in range(self.n_samples): ax[i].scatter(X, Y, self.data_matrix[j, :, :, i].T, **kwargs) @@ -995,10 +995,10 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, return self.copy(sample_points=sample_points, domain_range=domain_range) - if shifts.shape[0] != self.nsamples: + if shifts.shape[0] != self.n_samples: raise ValueError(f"shifts vector ({shifts.shape[0]}) must have the" f" same length than the number of samples " - f"({self.nsamples})") + f"({self.n_samples})") if eval_points is None: eval_points = self.sample_points @@ -1021,7 +1021,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, eval_points = np.asarray(eval_points) eval_points_repeat = np.repeat(eval_points[np.newaxis, :], - self.nsamples, axis=0) + self.n_samples, axis=0) # Solve problem with cartesian and matrix indexing if self.ndim_domain > 1: @@ -1057,8 +1057,8 @@ def compose(self, fd, *, eval_points=None): f"({self.ndim_domain})!=({fd.ndim_image}).") # All composed with same function - if fd.nsamples == 1 and self.nsamples != 1: - fd = fd.copy(data_matrix=np.repeat(fd.data_matrix, self.nsamples, + if fd.n_samples == 1 and self.n_samples != 1: + fd = fd.copy(data_matrix=np.repeat(fd.data_matrix, self.n_samples, axis=0)) if fd.ndim_domain == 1: @@ -1080,11 +1080,11 @@ def compose(self, fd, *, eval_points=None): lengths = [len(ax) for ax in eval_points] - eval_points_transformation = np.empty((self.nsamples, + eval_points_transformation = np.empty((self.n_samples, np.prod(lengths), self.ndim_domain)) - for i in range(self.nsamples): + for i in range(self.n_samples): eval_points_transformation[i] = np.array( list(map(np.ravel, grid_transformation[i].T)) ).T @@ -1092,7 +1092,7 @@ def compose(self, fd, *, eval_points=None): data_flatten = self(eval_points_transformation, aligned_evaluation=False) - data_matrix = data_flatten.reshape((self.nsamples, *lengths, + data_matrix = data_flatten.reshape((self.n_samples, *lengths, self.ndim_image)) return self.copy(data_matrix=data_matrix, diff --git a/skfda/representation/interpolation.py b/skfda/representation/interpolation.py index a8ec37f48..35075318d 100644 --- a/skfda/representation/interpolation.py +++ b/skfda/representation/interpolation.py @@ -168,7 +168,7 @@ def __init__(self, fdatagrid, k=1, s=0., monotone=False): self._fdatagrid = fdatagrid self._ndim_image = fdatagrid.ndim_image self._ndim_domain = fdatagrid.ndim_domain - self._nsamples = fdatagrid.nsamples + self._n_samples = fdatagrid.n_samples self._keepdims = fdatagrid.keepdims self._domain_range = fdatagrid.domain_range @@ -213,7 +213,7 @@ def _construct_spline_1_m(self, sample_points, data_matrix, k (integer): Order of the spline interpolators. Returns: - (np.ndarray): Array of size nsamples x ndim_image with the + (np.ndarray): Array of size n_samples x ndim_image with the corresponding interpolator of the sample i, and image dimension j in the entry (i,j) of the array. @@ -282,7 +282,7 @@ def _construct_spline_2_m(self, sample_points, data_matrix, k, s): k (integer): Order of the spline interpolators. Returns: - (np.ndarray): Array of size nsamples x ndim_image with the + (np.ndarray): Array of size n_samples x ndim_image with the corresponding interpolator of the sample i, and image dimension j in the entry (i,j) of the array. @@ -321,9 +321,9 @@ def _process_derivative_2_m(derivative): self._process_derivative = _process_derivative_2_m # Matrix of splines - spline = np.empty((self._nsamples, self._ndim_image), dtype=object) + spline = np.empty((self._n_samples, self._ndim_image), dtype=object) - for i in range(self._nsamples): + for i in range(self._n_samples): for j in range(self._ndim_image): spline[i, j] = RectBivariateSpline(sample_points[0], sample_points[1], @@ -349,7 +349,7 @@ def _construct_spline_n_m(self, sample_points, data_matrix, k): k (integer): Order of the spline interpolators. Returns: - (np.ndarray): Array of size nsamples x ndim_image with the + (np.ndarray): Array of size n_samples x ndim_image with the corresponding interpolator of the sample i, and image dimension j in the entry (i,j) of the array. @@ -383,9 +383,9 @@ def _spline_evaluator_n_m(spl, t, derivative): # Evaluator of splines called in evaluate self._spline_evaluator = _spline_evaluator_n_m - spline = np.empty((self._nsamples, self._ndim_image), dtype=object) + spline = np.empty((self._n_samples, self._ndim_image), dtype=object) - for i in range(self._nsamples): + for i in range(self._n_samples): for j in range(self._ndim_image): spline[i, j] = RegularGridInterpolator( sample_points, data_matrix[i, ..., j], method, False) @@ -435,7 +435,7 @@ def evaluator(spl_m): # Points evaluated inside the domain res = np.apply_along_axis(evaluator, 1, self._splines) - res = res.reshape(self._nsamples, eval_points.shape[0], + res = res.reshape(self._n_samples, eval_points.shape[0], self._ndim_image) return res @@ -467,7 +467,7 @@ def evaluate_composed(self, eval_points, *, derivative=0): argument. """ - shape = (self._nsamples, eval_points.shape[1], self._ndim_image) + shape = (self._n_samples, eval_points.shape[1], self._ndim_image) res = np.empty(shape) derivative = self._process_derivative(derivative) @@ -477,7 +477,7 @@ def evaluator(t, spl): """Evaluator of sample with image dimension equal to 1""" return self._spline_evaluator(spl[0], t, derivative) - for i in range(self._nsamples): + for i in range(self._n_samples): res[i] = evaluator(eval_points[i], self._splines[i]).reshape( (eval_points.shape[1], self._ndim_image)) @@ -487,7 +487,7 @@ def evaluator(t, spl_m): return np.array([self._spline_evaluator(spl, t, derivative) for spl in spl_m]).T - for i in range(self._nsamples): + for i in range(self._n_samples): res[i] = evaluator(eval_points[i], self._splines[i]) return res diff --git a/tests/test_grid.py b/tests/test_grid.py index 52a31d989..947807d42 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -65,7 +65,7 @@ def test_concatenate(self): fd1.axes_labels = ["x", "y"] fd = fd1.concatenate(fd2) - np.testing.assert_equal(fd.nsamples, 4) + np.testing.assert_equal(fd.n_samples, 4) np.testing.assert_equal(fd.ndim_image, 1) np.testing.assert_equal(fd.ndim_domain, 1) np.testing.assert_array_equal(fd.data_matrix[..., 0], @@ -81,7 +81,7 @@ def test_concatenate_coordinates(self): fd2.axes_labels = ["w", "t"] fd = fd1.concatenate(fd2, as_coordinates=True) - np.testing.assert_equal(fd.nsamples, 2) + np.testing.assert_equal(fd.n_samples, 2) np.testing.assert_equal(fd.ndim_image, 2) np.testing.assert_equal(fd.ndim_domain, 1) diff --git a/tests/test_pandas.py b/tests/test_pandas.py index a650daa1e..6f1b78530 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -16,28 +16,28 @@ def test_fdatagrid_series(self): series = pd.Series(self.fd) self.assertEqual( series.dtype, skfda.representation.grid.FDataGridDType) - self.assertEqual(len(series), self.fd.nsamples) + self.assertEqual(len(series), self.fd.n_samples) self.assertEqual(series[0], self.fd[0]) def test_fdatabasis_series(self): series = pd.Series(self.fd_basis) self.assertEqual( series.dtype, skfda.representation.basis.FDataBasisDType) - self.assertEqual(len(series), self.fd_basis.nsamples) + self.assertEqual(len(series), self.fd_basis.n_samples) self.assertEqual(series[0], self.fd_basis[0]) def test_fdatagrid_dataframe(self): df = pd.DataFrame({"function": self.fd}) self.assertEqual( df["function"].dtype, skfda.representation.grid.FDataGridDType) - self.assertEqual(len(df["function"]), self.fd.nsamples) + self.assertEqual(len(df["function"]), self.fd.n_samples) self.assertEqual(df["function"][0], self.fd[0]) def test_fdatabasis_dataframe(self): df = pd.DataFrame({"function": self.fd_basis}) self.assertEqual( df["function"].dtype, skfda.representation.basis.FDataBasisDType) - self.assertEqual(len(df["function"]), self.fd_basis.nsamples) + self.assertEqual(len(df["function"]), self.fd_basis.n_samples) self.assertEqual(df["function"][0], self.fd_basis[0]) def test_take(self): From 16c064ce0c159decf7ccec4013f3e82c667d8251 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 30 Aug 2019 11:35:20 +0200 Subject: [PATCH 194/222] Renamed attributes related with dimensions of functions: * `ndim_domain` is now `dim_domain` * `ndim_codomain` is now `dim_codomain` * `ndim_image` is removed, as it was redundant with `ndim_codomain` and the dimension is a property of the codomain and not the image. The old names where based in numpy nomenclature, which is matrix-centric. The new names try to reflect the usual conventions for functions. --- examples/plot_landmark_shift.py | 4 +- examples/plot_representation.py | 8 +- skfda/_neighbors/base.py | 2 +- skfda/datasets/_real_datasets.py | 4 +- skfda/datasets/_samples_generators.py | 46 +++--- skfda/exploratory/depth/_depth.py | 8 +- skfda/exploratory/depth/multivariate.py | 2 +- .../outliers/_directional_outlyingness.py | 6 +- skfda/exploratory/visualization/_boxplot.py | 28 ++-- .../visualization/_magnitude_shape_plot.py | 5 +- .../visualization/clustering_plots.py | 4 +- skfda/misc/_math.py | 2 +- skfda/misc/metrics.py | 16 +-- skfda/ml/clustering/base_kmeans.py | 46 +++--- skfda/preprocessing/registration/_elastic.py | 23 +-- .../registration/_landmark_registration.py | 2 +- .../registration/_registration_utils.py | 4 +- .../registration/_shift_registration.py | 2 +- skfda/preprocessing/smoothing/_linear.py | 2 +- skfda/representation/_functional_data.py | 134 ++++++++---------- skfda/representation/basis.py | 12 +- skfda/representation/evaluator.py | 16 +-- skfda/representation/extrapolation.py | 30 ++-- skfda/representation/grid.py | 68 ++++----- skfda/representation/interpolation.py | 40 +++--- tests/test_grid.py | 10 +- tests/test_metrics.py | 15 +- tests/test_neighbors.py | 22 ++- 28 files changed, 275 insertions(+), 286 deletions(-) diff --git a/examples/plot_landmark_shift.py b/examples/plot_landmark_shift.py index 70c50147c..e102a25fd 100644 --- a/examples/plot_landmark_shift.py +++ b/examples/plot_landmark_shift.py @@ -103,7 +103,7 @@ # fd = skfda.datasets.make_multimodal_samples(n_samples=3, points_per_dim=30, - ndim_domain=2, random_state=1) + dim_domain=2, random_state=1) fd.plot() @@ -111,7 +111,7 @@ # In this case the landmarks will be defined by tuples with 2 coordinates. # -landmarks = skfda.datasets.make_multimodal_landmarks(n_samples=3, ndim_domain=2, +landmarks = skfda.datasets.make_multimodal_landmarks(n_samples=3, dim_domain=2, random_state=1).squeeze() print(landmarks) diff --git a/examples/plot_representation.py b/examples/plot_representation.py index 318d5fd91..06a2fab70 100644 --- a/examples/plot_representation.py +++ b/examples/plot_representation.py @@ -56,11 +56,11 @@ ############################################################################### # This representation allows also functions with arbitrary dimensions of the # domain and codomain. -fd = skfda.datasets.make_multimodal_samples(n_samples=1, ndim_domain=2, - ndim_image=2) +fd = skfda.datasets.make_multimodal_samples(n_samples=1, dim_domain=2, + dim_codomain=2) -print(fd.ndim_domain) -print(fd.ndim_codomain) +print(fd.dim_domain) +print(fd.dim_codomain) fd.plot() diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index aae28c89d..2bf971d6c 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -639,7 +639,7 @@ def score(self, X, y, sample_weight=None): (float): Coefficient of determination. """ - if y.ndim_image != 1 or y.ndim_domain != 1: + if y.dim_codomain != 1 or y.dim_domain != 1: raise ValueError("Score not implemented for multivariate " "functional data.") diff --git a/skfda/datasets/_real_datasets.py b/skfda/datasets/_real_datasets.py index 1f7acd5da..ca5767837 100644 --- a/skfda/datasets/_real_datasets.py +++ b/skfda/datasets/_real_datasets.py @@ -460,8 +460,8 @@ def fetch_weather(return_X_y: bool = False): weather_daily = np.asarray(data["dailyAv"]) # Axes 0 and 1 must be transposed since in the downloaded dataset the - # data_matrix shape is (nfeatures, n_samples, ndim_image) while our - # data_matrix shape is (n_samples, nfeatures, ndim_image). + # data_matrix shape is (nfeatures, n_samples, dim_codomain) while our + # data_matrix shape is (n_samples, nfeatures, dim_codomain). temp_prec_daily = np.transpose(weather_daily[:, :, 0:2], axes=(1, 0, 2)) curves = FDataGrid(data_matrix=temp_prec_daily, diff --git a/skfda/datasets/_samples_generators.py b/skfda/datasets/_samples_generators.py index 4ad48bbb8..ac24b104d 100644 --- a/skfda/datasets/_samples_generators.py +++ b/skfda/datasets/_samples_generators.py @@ -112,7 +112,7 @@ def make_sinusoidal_process(n_samples: int = 15, n_features: int = 100, *, def make_multimodal_landmarks(n_samples: int = 15, *, n_modes: int = 1, - ndim_domain: int = 1, ndim_image: int = 1, + dim_domain: int = 1, dim_codomain: int = 1, start: float = -1, stop: float = 1, std: float = .05, random_state=None): """Generate landmarks points. @@ -130,8 +130,8 @@ def make_multimodal_landmarks(n_samples: int = 15, *, n_modes: int = 1, Args: n_samples: Total number of samples. n_modes: Number of modes of each sample. - ndim_domain: Number of dimensions of the domain. - ndim_image: Number of dimensions of the image + dim_domain: Number of dimensions of the domain. + dim_codomain: Number of dimensions of the codomain. start: Starting point of the samples. In multidimensional objects the starting point of the axis. stop: Ending point of the samples. In multidimensional objects the @@ -148,21 +148,21 @@ def make_multimodal_landmarks(n_samples: int = 15, *, n_modes: int = 1, random_state = sklearn.utils.check_random_state(random_state) modes_location = np.linspace(start, stop, n_modes + 2)[1:-1] - modes_location = np.repeat(modes_location[:, np.newaxis], ndim_domain, + modes_location = np.repeat(modes_location[:, np.newaxis], dim_domain, axis=1) - variation = random_state.multivariate_normal((0,) * ndim_domain, - std * np.eye(ndim_domain), + variation = random_state.multivariate_normal((0,) * dim_domain, + std * np.eye(dim_domain), size=(n_samples, - ndim_image, + dim_codomain, n_modes)) return modes_location + variation def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, - points_per_dim: int = 100, ndim_domain: int = 1, - ndim_image: int = 1, start: float = -1, + points_per_dim: int = 100, dim_domain: int = 1, + dim_codomain: int = 1, start: float = -1, stop: float = 1., std: float = .05, mode_std: float = .02, noise: float = .0, modes_location=None, random_state=None): @@ -187,10 +187,10 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, points_per_dim: Points per sample. If the object is multidimensional indicates the number of points for each dimension in the domain. The sample will have :math: - `\text{points_per_dim}^\text{ndim_domain}` points of + `\text{points_per_dim}^\text{dim_domain}` points of discretization. - ndim_domain: Number of dimensions of the domain. - ndim_image: Number of dimensions of the image + dim_domain: Number of dimensions of the domain. + dim_codomain: Number of dimensions of the image start: Starting point of the samples. In multidimensional objects the starting point of each axis. stop: Ending point of the samples. In multidimensional objects the @@ -211,8 +211,8 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, location = make_multimodal_landmarks(n_samples=n_samples, n_modes=n_modes, - ndim_domain=ndim_domain, - ndim_image=ndim_image, + dim_domain=dim_domain, + dim_codomain=dim_codomain, start=start, stop=stop, std=std, @@ -221,41 +221,41 @@ def make_multimodal_samples(n_samples: int = 15, *, n_modes: int = 1, else: location = np.asarray(modes_location) - shape = (n_samples, ndim_image, n_modes, ndim_domain) + shape = (n_samples, dim_codomain, n_modes, dim_domain) location = location.reshape(shape) axis = np.linspace(start, stop, points_per_dim) - if ndim_domain == 1: + if dim_domain == 1: sample_points = axis evaluation_grid = axis else: - sample_points = np.repeat(axis[:, np.newaxis], ndim_domain, axis=1).T + sample_points = np.repeat(axis[:, np.newaxis], dim_domain, axis=1).T meshgrid = np.meshgrid(*sample_points) - evaluation_grid = np.empty(meshgrid[0].shape + (ndim_domain,)) + evaluation_grid = np.empty(meshgrid[0].shape + (dim_domain,)) - for i in range(ndim_domain): + for i in range(dim_domain): evaluation_grid[..., i] = meshgrid[i] # Data matrix of the grid - shape = (n_samples,) + ndim_domain * (points_per_dim,) + (ndim_image,) + shape = (n_samples,) + dim_domain * (points_per_dim,) + (dim_codomain,) data_matrix = np.zeros(shape) # Covariance matrix of the samples - cov = mode_std * np.eye(ndim_domain) + cov = mode_std * np.eye(dim_domain) import itertools for i, j, k in itertools.product(range(n_samples), - range(ndim_image), + range(dim_codomain), range(n_modes)): data_matrix[i, ..., j] += multivariate_normal.pdf(evaluation_grid, location[i, j, k], cov) # Constant to make modes value aprox. 1 - data_matrix *= (2 * np.pi * mode_std) ** (ndim_domain / 2) + data_matrix *= (2 * np.pi * mode_std) ** (dim_domain / 2) data_matrix += random_state.normal(0, noise, size=data_matrix.shape) diff --git a/skfda/exploratory/depth/_depth.py b/skfda/exploratory/depth/_depth.py index 7c71f5c59..db1c86898 100644 --- a/skfda/exploratory/depth/_depth.py +++ b/skfda/exploratory/depth/_depth.py @@ -98,7 +98,7 @@ def _rank_samples(fdatagrid): """ - if fdatagrid.ndim_image > 1: + if fdatagrid.dim_codomain > 1: raise ValueError("Currently multivariate data is not allowed") ranks = np.zeros(fdatagrid.data_matrix.shape[:-1]) @@ -150,7 +150,7 @@ def band_depth(fdatagrid, *, pointwise=False): nchoose2 = n * (n - 1) / 2 ranks = _rank_samples(fdatagrid) - axis = tuple(range(1, fdatagrid.ndim_domain + 1)) + axis = tuple(range(1, fdatagrid.dim_domain + 1)) n_samples_above = fdatagrid.n_samples - np.amax(ranks, axis=axis) n_samples_below = np.amin(ranks, axis=axis) - 1 depth = ((n_samples_below * n_samples_above + fdatagrid.n_samples - 1) @@ -207,7 +207,7 @@ def modified_band_depth(fdatagrid, *, pointwise=False): n_samples_above = fdatagrid.n_samples - ranks n_samples_below = ranks - 1 match = n_samples_above * n_samples_below - axis = tuple(range(1, fdatagrid.ndim_domain + 1)) + axis = tuple(range(1, fdatagrid.dim_domain + 1)) if pointwise: depth_pointwise = (match + fdatagrid.n_samples - 1) / nchoose2 @@ -302,7 +302,7 @@ def fraiman_muniz_depth(fdatagrid, *, pointwise=False): """ - if fdatagrid.ndim_domain > 1 or fdatagrid.ndim_image > 1: + if fdatagrid.dim_domain > 1 or fdatagrid.dim_codomain > 1: raise ValueError("Currently multivariate data is not allowed") pointwise_depth = np.array([ diff --git a/skfda/exploratory/depth/multivariate.py b/skfda/exploratory/depth/multivariate.py index 0592e3d67..2d12cc6d4 100644 --- a/skfda/exploratory/depth/multivariate.py +++ b/skfda/exploratory/depth/multivariate.py @@ -10,7 +10,7 @@ def _stagel_donoho_outlyingness(X, *, pointwise=False): if pointwise is False: raise NotImplementedError("Only implemented pointwise") - if X.ndim_codomain == 1: + if X.dim_codomain == 1: # Special case, can be computed exactly m = X.data_matrix[..., 0] diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 3e8ebc62c..007bb586a 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -147,7 +147,7 @@ def directional_outlyingness_stats( Analysis 131 (2019): 50-65. """ - if fdatagrid.ndim_domain > 1: + if fdatagrid.dim_domain > 1: raise NotImplementedError("Only support 1 dimension on the domain.") if (pointwise_weights is not None and @@ -193,7 +193,7 @@ def directional_outlyingness_stats( fdatagrid.sample_points[0], axis=1) assert mean_dir_outlyingness.shape == ( - fdatagrid.n_samples, fdatagrid.ndim_codomain) + fdatagrid.n_samples, fdatagrid.dim_codomain) # Calculation variation directional outlyingness norm = np.square(la.norm(dir_outlyingness - @@ -437,7 +437,7 @@ def fit_predict(self, X, y=None): # (approximation of the tail of the distance distribution). # One per dimension (mean dir out) plus one (variational dir out) - dimension = X.ndim_codomain + 1 + dimension = X.dim_codomain + 1 if self._force_asymptotic: self.scaling_, self.cutoff_value_ = self._parameters_asymptotic( sample_size=X.n_samples, diff --git a/skfda/exploratory/visualization/_boxplot.py b/skfda/exploratory/visualization/_boxplot.py index ab8c43703..7a76f3630 100644 --- a/skfda/exploratory/visualization/_boxplot.py +++ b/skfda/exploratory/visualization/_boxplot.py @@ -102,18 +102,18 @@ class Boxplot(FDataBoxplot): Attributes: fdatagrid (FDataGrid): Object containing the data. - median (array, (fdatagrid.ndim_image, nsample_points)): contains + median (array, (fdatagrid.dim_codomain, nsample_points)): contains the median/s. - central_envelope (array, (fdatagrid.ndim_image, 2, nsample_points)): + central_envelope (array, (fdatagrid.dim_codomain, 2, nsample_points)): contains the central envelope/s. - non_outlying_envelope (array, (fdatagrid.ndim_image, 2, + non_outlying_envelope (array, (fdatagrid.dim_codomain, 2, nsample_points)): contains the non-outlying envelope/s. colormap (matplotlib.colors.LinearSegmentedColormap): Colormap from which the colors to represent the central regions are selected. - envelopes (array, (fdatagrid.ndim_image * ncentral_regions, 2, + envelopes (array, (fdatagrid.dim_codomain * ncentral_regions, 2, nsample_points)): contains the region envelopes. - outliers (array, (fdatagrid.ndim_image, fdatagrid.n_samples)): + outliers (array, (fdatagrid.dim_codomain, fdatagrid.n_samples)): contains the outliers. barcol (string): Color of the envelopes and vertical lines. outliercol (string): Color of the ouliers. @@ -229,7 +229,7 @@ def __init__(self, fdatagrid, depth_method=modified_band_depth, prob=[0.5], """ FDataBoxplot.__init__(self, factor) - if fdatagrid.ndim_domain != 1: + if fdatagrid.dim_domain != 1: raise ValueError( "Function only supports FDataGrid with domain dimension 1.") @@ -315,7 +315,7 @@ def show_full_outliers(self, boolean): def plot(self, fig=None, ax=None, nrows=None, ncols=None): """Visualization of the functional boxplot of the fdatagrid - (ndim_domain=1). + (dim_domain=1). Args: fig (figure object, optional): figure over with the graphs are @@ -351,7 +351,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): outliers = self.fdatagrid[self.outliers] - for m in range(self.fdatagrid.ndim_image): + for m in range(self.fdatagrid.dim_codomain): # Outliers for o in outliers: @@ -429,11 +429,11 @@ class SurfaceBoxplot(FDataBoxplot): Attributes: fdatagrid (FDataGrid): Object containing the data. - median (array, (fdatagrid.ndim_image, lx, ly)): contains + median (array, (fdatagrid.dim_codomain, lx, ly)): contains the median/s. - central_envelope (array, (fdatagrid.ndim_image, 2, lx, ly)): + central_envelope (array, (fdatagrid.dim_codomain, 2, lx, ly)): contains the central envelope/s. - non_outlying_envelope (array,(fdatagrid.ndim_image, 2, lx, ly)): + non_outlying_envelope (array,(fdatagrid.dim_codomain, 2, lx, ly)): contains the non-outlying envelope/s. colormap (matplotlib.colors.LinearSegmentedColormap): Colormap from which the colors to represent the central regions are selected. @@ -525,7 +525,7 @@ def __init__(self, fdatagrid, method=modified_band_depth, factor=1.5): """ FDataBoxplot.__init__(self, factor) - if fdatagrid.ndim_domain != 2: + if fdatagrid.dim_domain != 2: raise ValueError( "Class only supports FDataGrid with domain dimension 2.") @@ -593,7 +593,7 @@ def outcol(self, value): self._outcol = value def plot(self, fig=None, ax=None, nrows=None, ncols=None): - """Visualization of the surface boxplot of the fdatagrid (ndim_domain=2). + """Visualization of the surface boxplot of the fdatagrid (dim_domain=2). Args: fig (figure object, optional): figure over with the graphs are @@ -623,7 +623,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): ly = len(y) X, Y = np.meshgrid(x, y) - for m in range(self.fdatagrid.ndim_image): + for m in range(self.fdatagrid.dim_codomain): # mean sample ax[m].plot_wireframe(X, Y, np.squeeze(self.median[..., m]).T, diff --git a/skfda/exploratory/visualization/_magnitude_shape_plot.py b/skfda/exploratory/visualization/_magnitude_shape_plot.py index c0525dd22..3d5319555 100644 --- a/skfda/exploratory/visualization/_magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/_magnitude_shape_plot.py @@ -163,8 +163,9 @@ def __init__(self, fdatagrid, **kwargs): """ - if fdatagrid.ndim_image > 1: - raise NotImplementedError("Only support 1 dimension on the image.") + if fdatagrid.dim_codomain > 1: + raise NotImplementedError( + "Only support 1 dimension on the codomain.") self.outlier_detector = DirectionalOutlierDetector(**kwargs) diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering_plots.py index 35d49da47..ba6931486 100644 --- a/skfda/exploratory/visualization/clustering_plots.py +++ b/skfda/exploratory/visualization/clustering_plots.py @@ -148,7 +148,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, ncols(int): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - labels (numpy.ndarray, int: (n_samples, ndim_image)): 2-dimensional + labels (numpy.ndarray, int: (n_samples, dim_codomain)): 2-dimensional matrix where each row contains the number of cluster cluster that observation belongs to. sample_labels (list of str): contains in order the labels of each @@ -208,7 +208,7 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, mpatches.Patch(color=cluster_colors[i], label=cluster_labels[i])) - for j in range(fdatagrid.ndim_image): + for j in range(fdatagrid.dim_codomain): for i in range(fdatagrid.n_samples): ax[j].plot(fdatagrid.sample_points[0], fdatagrid.data_matrix[i, :, j], diff --git a/skfda/misc/_math.py b/skfda/misc/_math.py index 26212cec8..22cd635fc 100644 --- a/skfda/misc/_math.py +++ b/skfda/misc/_math.py @@ -179,7 +179,7 @@ def inner_product(fdatagrid, fdatagrid2): [ 1. , 0.5 ]]) """ - if fdatagrid.ndim_domain != 1: + if fdatagrid.dim_domain != 1: raise NotImplementedError("This method only works when the dimension " "of the domain of the FDatagrid object is " "one.") diff --git a/skfda/misc/metrics.py b/skfda/misc/metrics.py index 144ac65dc..9ab25b97c 100644 --- a/skfda/misc/metrics.py +++ b/skfda/misc/metrics.py @@ -25,8 +25,8 @@ def _cast_to_grid(fdata1, fdata2, eval_points=None, _check=True, **kwargs): if not _check: return fdata1, fdata2 - elif (fdata2.ndim_image != fdata1.ndim_image or - fdata2.ndim_domain != fdata1.ndim_domain): + elif (fdata2.dim_codomain != fdata1.dim_codomain or + fdata2.dim_domain != fdata1.dim_domain): raise ValueError("Objects should have the same dimensions") # Case different domain ranges @@ -97,14 +97,14 @@ def vectorial_norm(fdatagrid, p=2): First we will construct an example dataset with curves in :math:`\mathbb{R}^2`. - >>> fd = make_multimodal_samples(ndim_image=2, random_state=0) - >>> fd.ndim_image + >>> fd = make_multimodal_samples(dim_codomain=2, random_state=0) + >>> fd.dim_codomain 2 We will apply the euclidean norm >>> fd = vectorial_norm(fd, p=2) - >>> fd.ndim_image + >>> fd.dim_codomain 1 """ @@ -279,7 +279,7 @@ def norm_lp(fdatagrid, p=2, p2=2): if not (p == 'inf' or np.isinf(p)) and p < 1: raise ValueError(f"p must be equal or greater than 1.") - if fdatagrid.ndim_image > 1: + if fdatagrid.dim_codomain > 1: if p2 == 'inf': p2 = np.inf data_matrix = np.linalg.norm(fdatagrid.data_matrix, ord=p2, axis=-1, @@ -289,12 +289,12 @@ def norm_lp(fdatagrid, p=2, p2=2): if p == 'inf' or np.isinf(p): - if fdatagrid.ndim_domain == 1: + if fdatagrid.dim_domain == 1: res = np.max(data_matrix[..., 0], axis=1) else: res = np.array([np.max(sample) for sample in data_matrix]) - elif fdatagrid.ndim_domain == 1: + elif fdatagrid.dim_domain == 1: # Computes the norm, approximating the integral with Simpson's rule. res = scipy.integrate.simps(data_matrix[..., 0] ** p, diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index 5faf71578..cbc5472cd 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -36,7 +36,7 @@ def __init__(self, n_clusters, init, metric, n_init, max_iter, tol, init (FDataGrid, optional): Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must be of the shape (n_clusters, fdatagrid.ncol, - fdatagrid.ndim_image). Defaults to None, and the centers are + fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and returns a matrix with shape (fdatagrid1.n_samples, @@ -72,7 +72,7 @@ def _generic_clustering_checks(self, fdatagrid): are classified into different groups. """ - if fdatagrid.ndim_domain > 1: + if fdatagrid.dim_domain > 1: raise NotImplementedError( "Only support 1 dimension on the domain.") @@ -94,9 +94,9 @@ def _generic_clustering_checks(self, fdatagrid): "because the init parameter is set.") if self.init is not None and self.init.shape != ( - self.n_clusters, fdatagrid.ncol, fdatagrid.ndim_image): + self.n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain): raise ValueError("The init FDataGrid data_matrix should be of " - "shape (n_clusters, n_features, ndim_image) and " + "shape (n_clusters, n_features, dim_codomain) and " "gives the initial centers.") if self.max_iter < 1: @@ -247,7 +247,7 @@ def score(self, X, y=None, sample_weight=None): convention. Returns: - score (numpy.array: (fdatagrid.ndim_image)): negative *inertia_* + score (numpy.array: (fdatagrid.dim_codomain)): negative *inertia_* attribute. """ @@ -314,7 +314,7 @@ class KMeans(BaseKMeans): classified. Defaults to 2. init (FDataGrid, optional): Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must - be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). + be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and returns a matrix with shape (fdatagrid1.n_samples, fdatagrid2.n_samples). @@ -333,15 +333,15 @@ class KMeans(BaseKMeans): See :term:`Glossary `. Attributes: - labels_ (numpy.ndarray: (n_samples, ndim_image)): 2-dimensional matrix + labels_ (numpy.ndarray: (n_samples, dim_codomain)): 2-dimensional matrix in which each row contains the cluster that observation belongs to. cluster_centers_ (FDataGrid object): data_matrix of shape - (n_clusters, ncol, ndim_image) and contains the centroids for + (n_clusters, ncol, dim_codomain) and contains the centroids for each cluster. - inertia_ (numpy.ndarray, (fdatagrid.ndim_image)): Sum of squared + inertia_ (numpy.ndarray, (fdatagrid.dim_codomain)): Sum of squared distances of samples to their closest cluster center for each dimension. - n_iter_ (numpy.ndarray, (fdatagrid.ndim_image)): number of iterations + n_iter_ (numpy.ndarray, (fdatagrid.dim_codomain)): number of iterations the algorithm was run for each dimension. Example: @@ -374,7 +374,7 @@ def __init__(self, n_clusters=2, init=None, init (FDataGrid, optional): Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must be of the shape (n_clusters, fdatagrid.ncol, - fdatagrid.ndim_image). Defaults to None, and the centers are + fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and returns a matrix with shape (fdatagrid1.n_samples, @@ -416,7 +416,7 @@ def _kmeans_implementation(self, fdatagrid, random_state): array where each row contains the cluster that observation belongs to. - centers (numpy.ndarray: (n_clusters, ncol, ndim_image)): + centers (numpy.ndarray: (n_clusters, ncol, dim_codomain)): Contains the centroids for each cluster. distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): @@ -426,7 +426,7 @@ def _kmeans_implementation(self, fdatagrid, random_state): """ repetitions = 0 centers_old = np.zeros( - (self.n_clusters, fdatagrid.ncol, fdatagrid.ndim_image)) + (self.n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain)) if self.init is None: centers = self._init_centroids(fdatagrid, random_state) @@ -466,7 +466,7 @@ def fit(self, X, y=None, sample_weight=None): clustering_values = np.empty( (self.n_init, fdatagrid.n_samples)).astype(int) centers = np.empty((self.n_init, self.n_clusters, - fdatagrid.ncol, fdatagrid.ndim_image)) + fdatagrid.ncol, fdatagrid.dim_codomain)) distances_to_centers = np.empty( (self.n_init, fdatagrid.n_samples, self.n_clusters)) distances_to_their_center = np.empty( @@ -560,7 +560,7 @@ class FuzzyKMeans(BaseKMeans): classified. Defaults to 2. init (FDataGrid, optional): Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must - be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.ndim_image). + be of the shape (n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and returns a matrix with shape (fdatagrid1.n_samples, fdatagrid2.n_samples). @@ -583,15 +583,15 @@ class FuzzyKMeans(BaseKMeans): returned in the fuzzy algorithm. Defaults to 3. Attributes: - labels_ (numpy.ndarray: (n_samples, ndim_image)): 2-dimensional matrix + labels_ (numpy.ndarray: (n_samples, dim_codomain)): 2-dimensional matrix in which each row contains the cluster that observation belongs to. cluster_centers_ (FDataGrid object): data_matrix of shape - (n_clusters, ncol, ndim_image) and contains the centroids for + (n_clusters, ncol, dim_codomain) and contains the centroids for each cluster. - inertia_ (numpy.ndarray, (fdatagrid.ndim_image)): Sum of squared + inertia_ (numpy.ndarray, (fdatagrid.dim_codomain)): Sum of squared distances of samples to their closest cluster center for each dimension. - n_iter_ (numpy.ndarray, (fdatagrid.ndim_image)): number of iterations + n_iter_ (numpy.ndarray, (fdatagrid.dim_codomain)): number of iterations the algorithm was run for each dimension. @@ -625,7 +625,7 @@ def __init__(self, n_clusters=2, init=None, init (FDataGrid, optional): Contains the initial centers of the different clusters the algorithm starts with. Its data_marix must be of the shape (n_clusters, fdatagrid.ncol, - fdatagrid.ndim_image). + fdatagrid.dim_codomain). Defaults to None, and the centers are initialized randomly. metric (optional): metric that acceps two FDataGrid objects and returns a matrix with shape (fdatagrid1.n_samples, @@ -672,7 +672,7 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): 2-dimensional matrix where each row contains the membership value that observation has to each cluster. - centers (numpy.ndarray: (n_clusters, ncol, ndim_image)): + centers (numpy.ndarray: (n_clusters, ncol, dim_codomain)): Contains the centroids for each cluster. distances_to_centers (numpy.ndarray: (n_samples, n_clusters)): @@ -683,7 +683,7 @@ def _fuzzy_kmeans_implementation(self, fdatagrid, random_state): """ repetitions = 0 centers_old = np.zeros( - (self.n_clusters, fdatagrid.ncol, fdatagrid.ndim_image)) + (self.n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain)) U = np.empty((fdatagrid.n_samples, self.n_clusters)) distances_to_centers = np.empty((fdatagrid.n_samples, self.n_clusters)) @@ -750,7 +750,7 @@ def fit(self, X, y=None, sample_weight=None): (self.n_init, fdatagrid.n_samples, self.n_clusters)) centers = np.empty( (self.n_init, self.n_clusters, fdatagrid.ncol, - fdatagrid.ndim_image)) + fdatagrid.dim_codomain)) distances_to_centers = np.empty( (self.n_init, fdatagrid.n_samples, self.n_clusters)) distances_to_their_center = np.empty( diff --git a/skfda/preprocessing/registration/_elastic.py b/skfda/preprocessing/registration/_elastic.py index c363b2f2e..1af90a60f 100644 --- a/skfda/preprocessing/registration/_elastic.py +++ b/skfda/preprocessing/registration/_elastic.py @@ -56,13 +56,13 @@ def to_srsf(fdatagrid, eval_points=None): """ - if fdatagrid.ndim_domain > 1: + if fdatagrid.dim_domain > 1: raise ValueError("Only support functional objects with unidimensional " "domain.") - elif fdatagrid.ndim_image > 1: + elif fdatagrid.dim_codomain > 1: raise ValueError("Only support functional objects with unidimensional " - "image.") + "codomain.") elif eval_points is None: eval_points = fdatagrid.sample_points[0] @@ -119,11 +119,11 @@ def from_srsf(fdatagrid, initial=None, *, eval_points=None): """ - if fdatagrid.ndim_domain > 1: + if fdatagrid.dim_domain > 1: raise ValueError("Only support functional objects with " "unidimensional domain.") - elif fdatagrid.ndim_image > 1: + elif fdatagrid.dim_codomain > 1: raise ValueError("Only support functional objects with unidimensional " "image.") @@ -141,7 +141,8 @@ def from_srsf(fdatagrid, initial=None, *, eval_points=None): if initial is not None: initial = np.atleast_1d(initial) - initial = initial.reshape(fdatagrid.n_samples, 1, fdatagrid.ndim_image) + initial = initial.reshape( + fdatagrid.n_samples, 1, fdatagrid.dim_codomain) initial = np.repeat(initial, len(eval_points), axis=1) f_data_matrix += initial @@ -259,7 +260,7 @@ def elastic_registration_warping(fdatagrid, template=None, *, lam=0., """ # Check of params - if fdatagrid.ndim_domain != 1 or fdatagrid.ndim_image != 1: + if fdatagrid.dim_domain != 1 or fdatagrid.dim_codomain != 1: raise ValueError("Not supported multidimensional functional objects.") @@ -268,7 +269,7 @@ def elastic_registration_warping(fdatagrid, template=None, *, lam=0., **kwargs) elif ((template.n_samples != 1 and template.n_samples != fdatagrid.n_samples) - or template.ndim_domain != 1 or template.ndim_image != 1): + or template.dim_domain != 1 or template.dim_codomain != 1): raise ValueError("The template should contain one sample to align all" "the curves to the same function or the same number " @@ -572,11 +573,11 @@ def elastic_mean(fdatagrid, *, lam=0., center=True, iter=20, tol=1e-3, """ - if fdatagrid.ndim_domain != 1 or fdatagrid.ndim_image != 1: + if fdatagrid.dim_domain != 1 or fdatagrid.dim_codomain != 1: raise ValueError("Not supported multidimensional functional objects.") - if fdatagrid_srsf is not None and (fdatagrid_srsf.ndim_domain != 1 or - fdatagrid_srsf.ndim_image != 1): + if fdatagrid_srsf is not None and (fdatagrid_srsf.dim_domain != 1 or + fdatagrid_srsf.dim_codomain != 1): raise ValueError("Not supported multidimensional functional objects.") elif fdatagrid_srsf is None: diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index da6791e40..4582a9b2a 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -218,7 +218,7 @@ def landmark_registration_warping(fd, landmarks, *, location=None, FDataGrid(...) """ - if fd.ndim_domain > 1: + if fd.dim_domain > 1: raise NotImplementedError("Method only implemented for objects with" "domain dimension up to 1.") diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index 07d002f1a..36e129c71 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -131,7 +131,7 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, """ - if registered_fdata.ndim_domain != 1 or registered_fdata.ndim_image != 1: + if registered_fdata.dim_domain != 1 or registered_fdata.dim_codomain != 1: raise NotImplementedError if original_fdata.n_samples != registered_fdata.n_samples: @@ -268,7 +268,7 @@ def invert_warping(fdatagrid, *, eval_points=None): """ - if fdatagrid.ndim_image != 1 or fdatagrid.ndim_domain != 1: + if fdatagrid.dim_codomain != 1 or fdatagrid.dim_domain != 1: raise ValueError("Multidimensional object not supported.") if eval_points is None: diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 0eef2098e..12bb6b5bb 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -100,7 +100,7 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, # Initial estimation of the shifts - if fd.ndim_image > 1 or fd.ndim_domain > 1: + if fd.dim_codomain > 1 or fd.dim_domain > 1: raise NotImplementedError("Method for unidimensional data.") domain_range = fd.domain_range[0] diff --git a/skfda/preprocessing/smoothing/_linear.py b/skfda/preprocessing/smoothing/_linear.py index 730c8fd93..6f8f672fd 100644 --- a/skfda/preprocessing/smoothing/_linear.py +++ b/skfda/preprocessing/smoothing/_linear.py @@ -14,7 +14,7 @@ def _check_r_to_r(f): - if f.ndim_domain != 1 or f.ndim_codomain != 1: + if f.dim_domain != 1 or f.dim_codomain != 1: raise NotImplementedError("Only accepts functions from R to R") diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 42b5582c5..315a2492e 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -23,8 +23,8 @@ class FData(ABC, pandas.api.extensions.ExtensionArray): Attributes: n_samples (int): Number of samples. - ndim_domain (int): Dimension of the domain. - ndim_image (int): Dimension of the image. + dim_domain (int): Dimension of the domain. + dim_codomain (int): Dimension of the image. extrapolation (Extrapolation): Default extrapolation mode. dataset_label (str): name of the dataset. axes_labels (list): list containing the labels of the different @@ -54,11 +54,11 @@ def axes_labels(self, labels): if labels is not None: labels = np.asarray(labels) - if len(labels) > (self.ndim_domain + self.ndim_image): + if len(labels) > (self.dim_domain + self.dim_codomain): raise ValueError("There must be a label for each of the " "dimensions of the domain and the image.") - if len(labels) < (self.ndim_domain + self.ndim_image): - diff = (self.ndim_domain + self.ndim_image) - len(labels) + if len(labels) < (self.dim_domain + self.dim_codomain): + diff = (self.dim_domain + self.dim_codomain) - len(labels) labels = np.concatenate((labels, diff * [None])) self._axes_labels = labels @@ -76,7 +76,7 @@ def n_samples(self): @property @abstractmethod - def ndim_domain(self): + def dim_domain(self): """Return number of dimensions of the domain. Returns: @@ -87,24 +87,14 @@ def ndim_domain(self): @property @abstractmethod - def ndim_image(self): - """Return number of dimensions of the image. - - Returns: - int: Number of dimensions of the image. - - """ - pass - - @property - def ndim_codomain(self): + def dim_codomain(self): """Return number of dimensions of the codomain. Returns: int: Number of dimensions of the codomain. """ - return self.ndim_image + pass @property @abstractmethod @@ -168,9 +158,9 @@ def _reshape_eval_points(self, eval_points, evaluation_aligned): Returns: (np.ndarray): Numpy array with the eval_points, if evaluation_aligned is True with shape `number of evaluation points` - x `ndim_domain`. If the points are not aligned the shape of the + x `dim_domain`. If the points are not aligned the shape of the points will be `n_samples` x `number of evaluation points` - x `ndim_domain`. + x `dim_domain`. """ @@ -184,7 +174,7 @@ def _reshape_eval_points(self, eval_points, evaluation_aligned): if evaluation_aligned: # Samples evaluated at same eval points eval_points = eval_points.reshape((eval_points.shape[0], - self.ndim_domain)) + self.dim_domain)) else: # Different eval_points for each sample @@ -196,7 +186,7 @@ def _reshape_eval_points(self, eval_points, evaluation_aligned): eval_points = eval_points.reshape((eval_points.shape[0], eval_points.shape[1], - self.ndim_domain)) + self.dim_domain)) return eval_points @@ -205,8 +195,8 @@ def _extrapolation_index(self, eval_points): Args: eval_points (np.ndarray): Array with shape `n_eval_points` x - `ndim_domain` with the evaluation points, or shape ´n_samples´ x - `n_eval_points` x `ndim_domain` with different evaluation + `dim_domain` with the evaluation points, or shape ´n_samples´ x + `n_eval_points` x `dim_domain` with different evaluation points for each sample. Returns: @@ -266,7 +256,7 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, object. Returns: - (numpy.darray): Numpy array with ndim_domain + 1 dimensions with + (numpy.darray): Numpy array with dim_domain + 1 dimensions with the result of the evaluation. Raises: @@ -280,16 +270,16 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, lengths = [len(ax) for ax in axes] - if len(axes) != self.ndim_domain: + if len(axes) != self.dim_domain: raise ValueError(f"Length of axes should be " - f"{self.ndim_domain}") + f"{self.dim_domain}") eval_points = _coordinate_list(axes) res = self.evaluate(eval_points, derivative=derivative, extrapolation=extrapolation, keepdims=True) - elif self.ndim_domain == 1: + elif self.dim_domain == 1: eval_points = [ax.squeeze(0) for ax in axes] @@ -303,14 +293,14 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, if len(axes) != self.n_samples: raise ValueError("Should be provided a list of axis per " "sample") - elif len(axes[0]) != self.ndim_domain: + elif len(axes[0]) != self.dim_domain: raise ValueError(f"Incorrect length of axes. " - f"({self.ndim_domain}) != {len(axes[0])}") + f"({self.dim_domain}) != {len(axes[0])}") lengths = [len(ax) for ax in axes[0]] eval_points = np.empty((self.n_samples, np.prod(lengths), - self.ndim_domain)) + self.dim_domain)) for i in range(self.n_samples): eval_points[i] = _coordinate_list(axes[i]) @@ -324,8 +314,8 @@ def _evaluate_grid(self, axes, *, derivative=0, extrapolation=None, if keepdims is None: keepdims = self.keepdims - if self.ndim_image != 1 or keepdims: - shape += [self.ndim_image] + if self.dim_codomain != 1 or keepdims: + shape += [self.dim_codomain] # Roll the list of result in a list return res.reshape(shape) @@ -348,11 +338,11 @@ def _join_evaluation(self, index_matrix, index_ext, index_ev, Returns: (ndarray): Matrix with the points evaluated with shape - `n_samples` x `number of points evaluated` x `ndim_image`. + `n_samples` x `number of points evaluated` x `dim_codomain`. """ res = np.empty((self.n_samples, index_matrix.shape[-1], - self.ndim_image)) + self.dim_codomain)) # Case aligned evaluation if index_matrix.ndim == 1: @@ -377,13 +367,13 @@ def _evaluate(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - `(len(eval_points), ndim_domain)` with the evaluation points. + `(len(eval_points), dim_domain)` with the evaluation points. Each entry represents the coordinate of a point. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3d array with shape `(n_samples, - len(eval_points), ndim_image)` with the result of the + len(eval_points), dim_codomain)` with the result of the evaluation. The entry (i,j,k) will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. @@ -401,13 +391,13 @@ def _evaluate_composed(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - `(n_samples, len(eval_points), ndim_domain)` with the + `(n_samples, len(eval_points), dim_domain)` with the evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3d array with shape `(n_samples, - len(eval_points), ndim_image)` with the result of the + len(eval_points), dim_codomain)` with the result of the evaluation. The entry (i,j,k) will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. @@ -432,9 +422,9 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, grid (bool, optional): Whether to evaluate the results on a grid spanned by the input arrays, or at points specified by the input arrays. If true the eval_points should be a list of size - ndim_domain with the corresponding times for each axis. The + dim_domain with the corresponding times for each axis. The return matrix has shape n_samples x len(t1) x len(t2) x ... x - len(t_ndim_domain) x ndim_image. If the domain dimension is 1 + len(t_dim_domain) x dim_codomain. If the domain dimension is 1 the parameter has no efect. Defaults to False. keepdims (bool, optional): If the image dimension is equal to 1 and keepdims is True the return matrix has shape @@ -528,7 +518,7 @@ def evaluate(self, eval_points, *, derivative=0, extrapolation=None, keepdims = self.keepdims # Delete last axis if not keepdims and - if self.ndim_image == 1 and not keepdims: + if self.dim_codomain == 1 and not keepdims: res = res.reshape(res.shape[:-1]) return res @@ -551,9 +541,9 @@ def __call__(self, eval_points, *, derivative=0, extrapolation=None, grid (bool, optional): Whether to evaluate the results on a grid spanned by the input arrays, or at points specified by the input arrays. If true the eval_points should be a list of size - ndim_domain with the corresponding times for each axis. The + dim_domain with the corresponding times for each axis. The return matrix has shape n_samples x len(t1) x len(t2) x ... x - len(t_ndim_domain) x ndim_image. If the domain dimension is 1 + len(t_dim_domain) x dim_codomain. If the domain dimension is 1 the parameter has no efect. Defaults to False. keepdims (bool, optional): If the image dimension is equal to 1 and keepdims is True the return matrix has shape @@ -629,18 +619,18 @@ def set_figure_and_axes(self, nrows, ncols): """ - if self.ndim_domain == 1: + if self.dim_domain == 1: projection = None else: projection = '3d' if ncols is None and nrows is None: - ncols = int(np.ceil(np.sqrt(self.ndim_image))) - nrows = int(np.ceil(self.ndim_image / ncols)) + ncols = int(np.ceil(np.sqrt(self.dim_codomain))) + nrows = int(np.ceil(self.dim_codomain / ncols)) elif ncols is None and nrows is not None: - nrows = int(np.ceil(self.ndim_image / nrows)) + nrows = int(np.ceil(self.dim_codomain / nrows)) elif ncols is not None and nrows is None: - nrows = int(np.ceil(self.ndim_image / ncols)) + nrows = int(np.ceil(self.dim_codomain / ncols)) fig = plt.gcf() axes = fig.get_axes() @@ -661,19 +651,19 @@ def set_figure_and_axes(self, nrows, ncols): # If compatible uses the same figure if (same_projection and geometry == (nrows, ncols) and - self.ndim_image == len(axes)): + self.dim_codomain == len(axes)): return fig, axes else: # Create new figure if it is not compatible fig = plt.figure() - for i in range(self.ndim_image): + for i in range(self.dim_codomain): fig.add_subplot(nrows, ncols, i + 1, projection=projection) - if ncols > 1 and self.axes_labels is not None and self.ndim_image > 1: + if ncols > 1 and self.axes_labels is not None and self.dim_codomain > 1: plt.subplots_adjust(wspace=0.4) - if nrows > 1 and self.axes_labels is not None and self.ndim_image > 1: + if nrows > 1 and self.axes_labels is not None and self.dim_codomain > 1: plt.subplots_adjust(hspace=0.4) ax = fig.get_axes() @@ -694,9 +684,9 @@ def _get_labels_coordinates(self, key): labels = None else: - labels = self.axes_labels[:self.ndim_domain].tolist() + labels = self.axes_labels[:self.dim_domain].tolist() image_label = np.atleast_1d( - self.axes_labels[self.ndim_domain:][key]) + self.axes_labels[self.dim_domain:][key]) labels.extend(image_label.tolist()) return labels @@ -713,19 +703,19 @@ def _join_labels_coordinates(self, *others): self.concatenate(*others, as_coordinates=True). """ - # Labels should be None or a list of length self.ndim_domain + - # self.ndim_image. + # Labels should be None or a list of length self.dim_domain + + # self.dim_codomain. if self.axes_labels is None: - labels = (self.ndim_domain + self.ndim_image) * [None] + labels = (self.dim_domain + self.dim_codomain) * [None] else: labels = self.axes_labels.tolist() for other in others: if other.axes_labels is None: - labels.extend(other.ndim_image * [None]) + labels.extend(other.dim_codomain * [None]) else: - labels.extend(list(other.axes_labels[self.ndim_domain:])) + labels.extend(list(other.axes_labels[self.dim_domain:])) if all(label is None for label in labels): labels = None @@ -750,7 +740,7 @@ def set_labels(self, fig=None, ax=None, patches=None): if self.dataset_label is not None: fig.suptitle(self.dataset_label) ax = fig.get_axes() - if patches is not None and self.ndim_image > 1: + if patches is not None and self.dim_codomain > 1: fig.legend(handles=patches) elif patches is not None: ax[0].legend(handles=patches) @@ -762,7 +752,7 @@ def set_labels(self, fig=None, ax=None, patches=None): if self.axes_labels is not None: if ax[0].name == '3d': - for i in range(self.ndim_image): + for i in range(self.dim_codomain): if self.axes_labels[0] is not None: ax[i].set_xlabel(self.axes_labels[0]) if self.axes_labels[1] is not None: @@ -770,7 +760,7 @@ def set_labels(self, fig=None, ax=None, patches=None): if self.axes_labels[i + 2] is not None: ax[i].set_zlabel(self.axes_labels[i + 2]) else: - for i in range(self.ndim_image): + for i in range(self.dim_codomain): if self.axes_labels[0] is not None: ax[i].set_xlabel(self.axes_labels[0]) if self.axes_labels[i + 1] is not None: @@ -803,7 +793,7 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, * ax (list): axes in which the graphs are plotted. """ - if self.ndim_domain > 2: + if self.dim_domain > 2: raise NotImplementedError("Plot only supported for functional data" "modeled in at most 3 dimensions.") @@ -811,11 +801,11 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, raise ValueError("fig and axes parameters cannot be passed as " "arguments at the same time.") - if fig is not None and len(fig.get_axes()) != self.ndim_image: + if fig is not None and len(fig.get_axes()) != self.dim_codomain: raise ValueError("Number of axes of the figure must be equal to" "the dimension of the image.") - if ax is not None and len(ax) != self.ndim_image: + if ax is not None and len(ax) != self.dim_codomain: raise ValueError("Number of axes must be equal to the dimension " "of the image.") @@ -827,7 +817,7 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, " fig is None and ax is None.") if ((nrows is not None and ncols is not None) - and ((nrows * ncols) < self.ndim_image)): + and ((nrows * ncols) < self.dim_codomain)): raise ValueError("The number of columns and the number of rows " "specified is incorrect.") @@ -891,8 +881,8 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, label_names (list of str): name of each of the groups which appear in a legend, there must be one for each one. Defaults to None and the legend is not shown. - **kwargs: if ndim_domain is 1, keyword arguments to be passed to - the matplotlib.pyplot.plot function; if ndim_domain is 2, + **kwargs: if dim_domain is 1, keyword arguments to be passed to + the matplotlib.pyplot.plot function; if dim_domain is 2, keyword arguments to be passed to the matplotlib.pyplot.plot_surface function. @@ -976,7 +966,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, sample_colors = np.empty((self.n_samples,)).astype(str) next_color = True - if self.ndim_domain == 1: + if self.dim_domain == 1: if npoints is None: npoints = constants.N_POINTS_UNIDIMENSIONAL_PLOT_MESH @@ -985,7 +975,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, eval_points = np.linspace(*domain_range[0], npoints) mat = self(eval_points, derivative=derivative, keepdims=True) - for i in range(self.ndim_image): + for i in range(self.dim_codomain): for j in range(self.n_samples): if sample_labels is None and next_color: sample_colors[j] = ax[i]._get_lines.get_next_color() @@ -1012,7 +1002,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, X, Y = np.meshgrid(x, y, indexing='ij') - for i in range(self.ndim_image): + for i in range(self.dim_codomain): for j in range(self.n_samples): if sample_labels is None and next_color: sample_colors[j] = ax[i]._get_lines.get_next_color() diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 1f99d2689..80e51a004 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -1598,7 +1598,7 @@ def __getitem__(self, key): def __len__(self): """Return the number of coordinates.""" - return self._fdatabasis.ndim_image + return self._fdatabasis.dim_codomain def __init__(self, basis, coefficients, *, dataset_label=None, axes_labels=None, extrapolation=None, keepdims=False): @@ -1718,14 +1718,14 @@ def n_samples(self): return self.coefficients.shape[0] @property - def ndim_domain(self): + def dim_domain(self): """Return number of dimensions of the domain.""" # Only domain dimension equal to 1 is supported return 1 @property - def ndim_image(self): + def dim_codomain(self): """Return number of dimensions of the image.""" # Only image dimension equal to 1 is supported @@ -1849,7 +1849,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, :obj:`FDataBasis` with the shifted data. """ - if self.ndim_image > 1 or self.ndim_domain > 1: + if self.dim_codomain > 1 or self.dim_domain > 1: raise ValueError domain_range = self.domain_range[0] @@ -2041,7 +2041,7 @@ def to_grid(self, eval_points=None): """ - if self.ndim_image > 1 or self.ndim_domain > 1: + if self.dim_codomain > 1 or self.dim_domain > 1: raise NotImplementedError if eval_points is None: @@ -2326,7 +2326,7 @@ def compose(self, fd, *, eval_points=None, **kwargs): grid = self.to_grid().compose(fd, eval_points=eval_points) - if fd.ndim_domain == 1: + if fd.dim_domain == 1: basis = self.basis.rescale(fd.domain_range[0]) composition = grid.to_basis(basis, **kwargs) else: diff --git a/skfda/representation/evaluator.py b/skfda/representation/evaluator.py index 697848ee8..896a17147 100644 --- a/skfda/representation/evaluator.py +++ b/skfda/representation/evaluator.py @@ -62,13 +62,13 @@ def evaluate(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - ``(number_eval_points, ndim_domain)`` with the + ``(number_eval_points, dim_domain)`` with the evaluation points. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3d array with shape - ``(n_samples, number_eval_points, ndim_image)`` with the + ``(n_samples, number_eval_points, dim_codomain)`` with the result of the evaluation. The entry ``(i,j,k)`` will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. @@ -87,13 +87,13 @@ def evaluate_composed(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - ``(n_samples, number_eval_points, ndim_domain)`` with the + ``(n_samples, number_eval_points, dim_domain)`` with the evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3d array with shape - ``(n_samples, number_eval_points, ndim_image)`` with the + ``(n_samples, number_eval_points, dim_codomain)`` with the result of the evaluation. The entry ``(i,j,k)`` will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. @@ -131,13 +131,13 @@ def evaluate(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - `(len(eval_points), ndim_domain)` with the evaluation points. + `(len(eval_points), dim_domain)` with the evaluation points. Each entry represents the coordinate of a point. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3-d array with shape `(n_samples, - len(eval_points), ndim_image)` with the result of the + len(eval_points), dim_codomain)` with the result of the evaluation. The entry (i,j,k) will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. @@ -158,13 +158,13 @@ def evaluate_composed(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - `(n_samples, number_eval_points, ndim_domain)` with the + `(n_samples, number_eval_points, dim_domain)` with the evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3d array with shape `(n_samples, - number_eval_points, ndim_image)` with the result of the + number_eval_points, dim_codomain)` with the result of the evaluation. The entry (i,j,k) will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. diff --git a/skfda/representation/extrapolation.py b/skfda/representation/extrapolation.py index 21f14e3c4..60baddaca 100644 --- a/skfda/representation/extrapolation.py +++ b/skfda/representation/extrapolation.py @@ -51,13 +51,13 @@ def _periodic_evaluation(fdata, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` - x `ndim_image`. + `n_eval_points` x `dim_codomain` or `n_samples` x `n_eval_points` + x `dim_codomain`. derivate (numeric, optional): Order of derivative to be evaluated. Returns: (numpy.ndarray): numpy array with the evaluation of the points in - a matrix with shape `n_samples` x `n_eval_points`x `ndim_image`. + a matrix with shape `n_samples` x `n_eval_points`x `dim_codomain`. """ domain_range = np.asarray(fdata.domain_range) @@ -117,18 +117,18 @@ def _boundary_evaluation(fdata, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` - x `ndim_image`. + `n_eval_points` x `dim_codomain` or `n_samples` x `n_eval_points` + x `dim_codomain`. derivate (numeric, optional): Order of derivative to be evaluated. Returns: (numpy.ndarray): numpy array with the evaluation of the points in - a matrix with shape `n_samples` x `n_eval_points`x `ndim_image`. + a matrix with shape `n_samples` x `n_eval_points`x `dim_codomain`. """ domain_range = fdata.domain_range - for i in range(fdata.ndim_domain): + for i in range(fdata.dim_domain): a, b = domain_range[i] eval_points[eval_points[..., i] < a, i] = a eval_points[eval_points[..., i] > b, i] = b @@ -190,8 +190,8 @@ def _exception_evaluation(fdata, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` - x `ndim_image`. + `n_eval_points` x `dim_codomain` or `n_samples` x `n_eval_points` + x `dim_codomain`. derivate (numeric, optional): Order of derivative to be evaluated. Raises: @@ -264,7 +264,7 @@ def __init__(self, fdata, fill_value): def _fill(self, eval_points): shape = (self.fdata.n_samples, eval_points.shape[-2], - self.fdata.ndim_image) + self.fdata.dim_codomain) return np.full(shape, self.fill_value) def evaluate(self, eval_points, *, derivative=0): @@ -275,13 +275,13 @@ def evaluate(self, eval_points, *, derivative=0): fdata (:class:´FData´): Object where the evaluation is taken place. eval_points (:class: numpy.ndarray): Numpy array with the evalation points outside the domain range. The shape of the array may be - `n_eval_points` x `ndim_image` or `n_samples` x `n_eval_points` - x `ndim_image`. + `n_eval_points` x `dim_codomain` or `n_samples` x `n_eval_points` + x `dim_codomain`. derivate (numeric, optional): Order of derivative to be evaluated. Returns: (numpy.ndarray): numpy array with the evaluation of the points in - a matrix with shape `n_samples` x `n_eval_points`x `ndim_image`. + a matrix with shape `n_samples` x `n_eval_points`x `dim_codomain`. """ return self._fill(eval_points) @@ -298,13 +298,13 @@ def evaluate_composed(self, eval_points, *, derivative=0): Args: eval_points (numpy.ndarray): Numpy array with shape - `(n_samples, number_eval_points, ndim_domain)` with the + `(n_samples, number_eval_points, dim_domain)` with the evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (numpy.darray): Numpy 3d array with shape `(n_samples, - number_eval_points, ndim_image)` with the result of the + number_eval_points, dim_codomain)` with the result of the evaluation. The entry (i,j,k) will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 035f6e047..8df4478cd 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -83,7 +83,7 @@ class FDataGrid(FData): >>> data_matrix = [[[1, 0.3], [2, 0.4]], [[2, 0.5], [3, 0.6]]] >>> sample_points = [2, 4] >>> fd = FDataGrid(data_matrix, sample_points) - >>> fd.ndim_domain, fd.ndim_image + >>> fd.dim_domain, fd.dim_codomain (1, 2) Representation of a functional data object with 2 samples @@ -92,7 +92,7 @@ class FDataGrid(FData): >>> data_matrix = [[[1, 0.3], [2, 0.4]], [[2, 0.5], [3, 0.6]]] >>> sample_points = [[2, 4], [3,6]] >>> fd = FDataGrid(data_matrix, sample_points) - >>> fd.ndim_domain, fd.ndim_image + >>> fd.dim_domain, fd.dim_codomain (2, 1) """ @@ -120,7 +120,7 @@ def __getitem__(self, key): def __len__(self): """Return the number of coordinates.""" - return self._fdatagrid.ndim_image + return self._fdatagrid.dim_codomain def __init__(self, data_matrix, sample_points=None, domain_range=None, dataset_label=None, @@ -160,7 +160,7 @@ def __init__(self, data_matrix, sample_points=None, self.sample_points = _list_of_arrays(sample_points) - data_shape = self.data_matrix.shape[1: 1 + self.ndim_domain] + data_shape = self.data_matrix.shape[1: 1 + self.dim_domain] sample_points_shape = [len(i) for i in self.sample_points] if not np.array_equal(data_shape, sample_points_shape): @@ -171,7 +171,7 @@ def __init__(self, data_matrix, sample_points=None, self._sample_range = np.array( [(self.sample_points[i][0], self.sample_points[i][-1]) - for i in range(self.ndim_domain)]) + for i in range(self.dim_domain)]) if domain_range is None: self._domain_range = self.sample_range @@ -183,9 +183,9 @@ def __init__(self, data_matrix, sample_points=None, # dimensions in the domain and 2 columns if (self._domain_range.ndim != 2 or self._domain_range.shape[1] != 2 - or self._domain_range.shape[0] != self.ndim_domain): + or self._domain_range.shape[0] != self.dim_domain): raise ValueError("Incorrect shape of domain_range.") - for i in range(self.ndim_domain): + for i in range(self.dim_domain): if (self._domain_range[i, 0] > self.sample_points[i][0] or self._domain_range[i, -1] < self.sample_points[i] [-1]): @@ -193,7 +193,7 @@ def __init__(self, data_matrix, sample_points=None, "range.") # Adjust the data matrix if the dimension of the image is one - if self.data_matrix.ndim == 1 + self.ndim_domain: + if self.data_matrix.ndim == 1 + self.dim_domain: self.data_matrix = self.data_matrix[..., np.newaxis] self.interpolator = interpolator @@ -219,7 +219,7 @@ def round(self, decimals=0): return self.copy(data_matrix=self.data_matrix.round(decimals)) @property - def ndim_domain(self): + def dim_domain(self): """Return number of dimensions of the domain. Returns: @@ -229,7 +229,7 @@ def ndim_domain(self): return len(self.sample_points) @property - def ndim_image(self): + def dim_codomain(self): """Return number of dimensions of the image. Returns: @@ -240,7 +240,7 @@ def ndim_image(self): # The dimension of the image is the length of the array that can # be extracted from the data_matrix using all the dimensions of # the domain. - return self.data_matrix.shape[1 + self.ndim_domain] + return self.data_matrix.shape[1 + self.dim_domain] # If there is no array that means the dimension of the image is 1. except IndexError: return 1 @@ -259,8 +259,8 @@ def coordinates(self): We will construct a dataset of curves in :math:`\mathbb{R}^3` >>> from skfda.datasets import make_multimodal_samples - >>> fd = make_multimodal_samples(ndim_image=3, random_state=0) - >>> fd.ndim_image + >>> fd = make_multimodal_samples(dim_codomain=3, random_state=0) + >>> fd.dim_codomain 3 The functions of this dataset are vectorial functions @@ -273,20 +273,20 @@ def coordinates(self): The object returned has image dimension equal to 1 - >>> fd_0.ndim_image + >>> fd_0.dim_codomain 1 Or we can get multiple components, it can be accesed as a 1-d numpy array of coordinates, for example, :math:`(f_0(t), f_1(t))`. >>> fd_01 = fd.coordinates[0:2] - >>> fd_01.ndim_image + >>> fd_01.dim_codomain 2 We can use this method to iterate throught all the coordinates. >>> for fd_i in fd.coordinates: - ... fd_i.ndim_image + ... fd_i.dim_codomain 1 1 1 @@ -470,7 +470,7 @@ def derivative(self, order=1): ...) """ - if self.ndim_domain != 1: + if self.dim_domain != 1: raise NotImplementedError( "This method only works when the dimension " "of the domain of the FDatagrid object is " @@ -478,7 +478,7 @@ def derivative(self, order=1): if order < 1: raise ValueError("The order of a derivative has to be greater " "or equal than 1.") - if self.ndim_domain > 1 or self.ndim_image > 1: + if self.dim_domain > 1 or self.dim_codomain > 1: raise NotImplementedError("Not implemented for 2 or more" " dimensional data.") if np.isnan(self.data_matrix).any(): @@ -822,8 +822,8 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): """ fig, ax = self.generic_plotting_checks(fig, ax, nrows, ncols) - if self.ndim_domain == 1: - for i in range(self.ndim_image): + if self.dim_domain == 1: + for i in range(self.dim_codomain): for j in range(self.n_samples): ax[i].scatter(self.sample_points[0], self.data_matrix[j, :, i].T, **kwargs) @@ -831,7 +831,7 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): X = self.sample_points[0] Y = self.sample_points[1] X, Y = np.meshgrid(X, Y) - for i in range(self.ndim_image): + for i in range(self.dim_codomain): for j in range(self.n_samples): ax[i].scatter(X, Y, self.data_matrix[j, :, :, i].T, **kwargs) @@ -868,10 +868,10 @@ def to_basis(self, basis, **kwargs): array([[ 0. , 0.71, 0.71]]) """ - if self.ndim_domain > 1: + if self.dim_domain > 1: raise NotImplementedError("Only support 1 dimension on the " "domain.") - elif self.ndim_image > 1: + elif self.dim_codomain > 1: raise NotImplementedError("Only support 1 dimension on the " "image.") @@ -981,11 +981,11 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, shifts = np.array(shifts) # Case unidimensional treated as the multidimensional - if self.ndim_domain == 1 and shifts.ndim == 1 and shifts.shape[0] != 1: + if self.dim_domain == 1 and shifts.ndim == 1 and shifts.shape[0] != 1: shifts = shifts[:, np.newaxis] # Case same shift for all the curves - if shifts.shape[0] == self.ndim_domain and shifts.ndim == 1: + if shifts.shape[0] == self.dim_domain and shifts.ndim == 1: # Column vector with shapes shifts = np.atleast_2d(shifts).T @@ -1013,7 +1013,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, eval_points = [eval_points[i][ np.logical_and(eval_points[i] >= domain[i, 0], eval_points[i] <= domain[i, 1])] - for i in range(self.ndim_domain)] + for i in range(self.dim_domain)] else: domain = self.domain_range @@ -1024,7 +1024,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, self.n_samples, axis=0) # Solve problem with cartesian and matrix indexing - if self.ndim_domain > 1: + if self.dim_domain > 1: shifts[:, :2] = np.flip(shifts[:, :2], axis=1) shifts = np.repeat(shifts[..., np.newaxis], @@ -1051,17 +1051,17 @@ def compose(self, fd, *, eval_points=None): eval_points (array_like): Points to perform the evaluation. """ - if self.ndim_domain != fd.ndim_image: + if self.dim_domain != fd.dim_codomain: raise ValueError(f"Dimension of codomain of first function do not " f"match with the domain of the second function " - f"({self.ndim_domain})!=({fd.ndim_image}).") + f"({self.dim_domain})!=({fd.dim_codomain}).") # All composed with same function if fd.n_samples == 1 and self.n_samples != 1: fd = fd.copy(data_matrix=np.repeat(fd.data_matrix, self.n_samples, axis=0)) - if fd.ndim_domain == 1: + if fd.dim_domain == 1: if eval_points is None: try: eval_points = fd.sample_points[0] @@ -1082,7 +1082,7 @@ def compose(self, fd, *, eval_points=None): eval_points_transformation = np.empty((self.n_samples, np.prod(lengths), - self.ndim_domain)) + self.dim_domain)) for i in range(self.n_samples): eval_points_transformation[i] = np.array( @@ -1093,7 +1093,7 @@ def compose(self, fd, *, eval_points=None): aligned_evaluation=False) data_matrix = data_flatten.reshape((self.n_samples, *lengths, - self.ndim_image)) + self.dim_codomain)) return self.copy(data_matrix=data_matrix, sample_points=eval_points, @@ -1128,11 +1128,11 @@ def __getitem__(self, key): if isinstance(key, tuple): # If there are not values for every dimension, the remaining ones # are kept - key += (slice(None),) * (self.ndim_domain + 1 - len(key)) + key += (slice(None),) * (self.dim_domain + 1 - len(key)) sample_points = [self.sample_points[i][subkey] for i, subkey in enumerate( - key[1:1 + self.ndim_domain])] + key[1:1 + self.dim_domain])] return self.copy(data_matrix=self.data_matrix[key], sample_points=sample_points) diff --git a/skfda/representation/interpolation.py b/skfda/representation/interpolation.py index 35075318d..1bf6d9390 100644 --- a/skfda/representation/interpolation.py +++ b/skfda/representation/interpolation.py @@ -166,13 +166,13 @@ def __init__(self, fdatagrid, k=1, s=0., monotone=False): data_matrix = fdatagrid.data_matrix self._fdatagrid = fdatagrid - self._ndim_image = fdatagrid.ndim_image - self._ndim_domain = fdatagrid.ndim_domain + self._dim_codomain = fdatagrid.dim_codomain + self._dim_domain = fdatagrid.dim_domain self._n_samples = fdatagrid.n_samples self._keepdims = fdatagrid.keepdims self._domain_range = fdatagrid.domain_range - if self._ndim_domain == 1: + if self._dim_domain == 1: self._splines = self._construct_spline_1_m(sample_points, data_matrix, k, s, monotone) @@ -180,7 +180,7 @@ def __init__(self, fdatagrid, k=1, s=0., monotone=False): raise ValueError("Monotone interpolation is only supported with " "domain dimension equal to 1.") - elif self._ndim_domain == 2: + elif self._dim_domain == 2: self._splines = self._construct_spline_2_m(sample_points, data_matrix, k, s) @@ -213,7 +213,7 @@ def _construct_spline_1_m(self, sample_points, data_matrix, k (integer): Order of the spline interpolators. Returns: - (np.ndarray): Array of size n_samples x ndim_image with the + (np.ndarray): Array of size n_samples x dim_codomain with the corresponding interpolator of the sample i, and image dimension j in the entry (i,j) of the array. @@ -282,7 +282,7 @@ def _construct_spline_2_m(self, sample_points, data_matrix, k, s): k (integer): Order of the spline interpolators. Returns: - (np.ndarray): Array of size n_samples x ndim_image with the + (np.ndarray): Array of size n_samples x dim_codomain with the corresponding interpolator of the sample i, and image dimension j in the entry (i,j) of the array. @@ -321,10 +321,10 @@ def _process_derivative_2_m(derivative): self._process_derivative = _process_derivative_2_m # Matrix of splines - spline = np.empty((self._n_samples, self._ndim_image), dtype=object) + spline = np.empty((self._n_samples, self._dim_codomain), dtype=object) for i in range(self._n_samples): - for j in range(self._ndim_image): + for j in range(self._dim_codomain): spline[i, j] = RectBivariateSpline(sample_points[0], sample_points[1], data_matrix[i, :, :, j], @@ -349,7 +349,7 @@ def _construct_spline_n_m(self, sample_points, data_matrix, k): k (integer): Order of the spline interpolators. Returns: - (np.ndarray): Array of size n_samples x ndim_image with the + (np.ndarray): Array of size n_samples x dim_codomain with the corresponding interpolator of the sample i, and image dimension j in the entry (i,j) of the array. @@ -383,10 +383,10 @@ def _spline_evaluator_n_m(spl, t, derivative): # Evaluator of splines called in evaluate self._spline_evaluator = _spline_evaluator_n_m - spline = np.empty((self._n_samples, self._ndim_image), dtype=object) + spline = np.empty((self._n_samples, self._dim_codomain), dtype=object) for i in range(self._n_samples): - for j in range(self._ndim_image): + for j in range(self._dim_codomain): spline[i, j] = RegularGridInterpolator( sample_points, data_matrix[i, ..., j], method, False) @@ -404,13 +404,13 @@ def evaluate(self, eval_points, *, derivative=0): Args: eval_points (np.ndarray): Numpy array with shape - `(n_samples, number_eval_points, ndim_domain)` with the + `(n_samples, number_eval_points, dim_domain)` with the evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (np.darray): Numpy 3d array with shape `(n_samples, - number_eval_points, ndim_image)` with the result of the + number_eval_points, dim_codomain)` with the result of the evaluation. The entry (i,j,k) will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. @@ -422,7 +422,7 @@ def evaluate(self, eval_points, *, derivative=0): derivative = self._process_derivative(derivative) # Constructs the evaluator for t_eval - if self._ndim_image == 1: + if self._dim_codomain == 1: def evaluator(spl): """Evaluator of object with image dimension equal to 1.""" return self._spline_evaluator(spl[0], eval_points, derivative) @@ -436,7 +436,7 @@ def evaluator(spl_m): # Points evaluated inside the domain res = np.apply_along_axis(evaluator, 1, self._splines) res = res.reshape(self._n_samples, eval_points.shape[0], - self._ndim_image) + self._dim_codomain) return res @@ -452,13 +452,13 @@ def evaluate_composed(self, eval_points, *, derivative=0): Args: eval_points (np.ndarray): Numpy array with shape - `(n_samples, number_eval_points, ndim_domain)` with the + `(n_samples, number_eval_points, dim_domain)` with the evaluation points for each sample. derivative (int, optional): Order of the derivative. Defaults to 0. Returns: (np.darray): Numpy 3d array with shape `(n_samples, - number_eval_points, ndim_image)` with the result of the + number_eval_points, dim_codomain)` with the result of the evaluation. The entry (i,j,k) will contain the value k-th image dimension of the i-th sample, at the j-th evaluation point. @@ -467,19 +467,19 @@ def evaluate_composed(self, eval_points, *, derivative=0): argument. """ - shape = (self._n_samples, eval_points.shape[1], self._ndim_image) + shape = (self._n_samples, eval_points.shape[1], self._dim_codomain) res = np.empty(shape) derivative = self._process_derivative(derivative) - if self._ndim_image == 1: + if self._dim_codomain == 1: def evaluator(t, spl): """Evaluator of sample with image dimension equal to 1""" return self._spline_evaluator(spl[0], t, derivative) for i in range(self._n_samples): res[i] = evaluator(eval_points[i], self._splines[i]).reshape( - (eval_points.shape[1], self._ndim_image)) + (eval_points.shape[1], self._dim_codomain)) else: def evaluator(t, spl_m): diff --git a/tests/test_grid.py b/tests/test_grid.py index 947807d42..daaeb054e 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -66,8 +66,8 @@ def test_concatenate(self): fd = fd1.concatenate(fd2) np.testing.assert_equal(fd.n_samples, 4) - np.testing.assert_equal(fd.ndim_image, 1) - np.testing.assert_equal(fd.ndim_domain, 1) + np.testing.assert_equal(fd.dim_codomain, 1) + np.testing.assert_equal(fd.dim_domain, 1) np.testing.assert_array_equal(fd.data_matrix[..., 0], [[1, 2, 3, 4, 5], [2, 3, 4, 5, 6], [3, 4, 5, 6, 7], [4, 5, 6, 7, 8]]) @@ -82,8 +82,8 @@ def test_concatenate_coordinates(self): fd = fd1.concatenate(fd2, as_coordinates=True) np.testing.assert_equal(fd.n_samples, 2) - np.testing.assert_equal(fd.ndim_image, 2) - np.testing.assert_equal(fd.ndim_domain, 1) + np.testing.assert_equal(fd.dim_codomain, 2) + np.testing.assert_equal(fd.dim_domain, 1) np.testing.assert_array_equal(fd.data_matrix, [[[1, 3], [2, 4], [3, 5], [4, 6]], @@ -118,7 +118,7 @@ def test_coordinates(self): fd3 = fd1.concatenate(fd2, fd1, fd, as_coordinates=True) # Multiple indexation - np.testing.assert_equal(fd3.ndim_image, 5) + np.testing.assert_equal(fd3.dim_codomain, 5) np.testing.assert_array_equal(fd3.coordinates[:2].data_matrix, fd.data_matrix) np.testing.assert_array_equal(fd3.coordinates[-2:].data_matrix, diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 47d614deb..0ab4aebda 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -4,10 +4,10 @@ import numpy as np from skfda import FDataGrid, FDataBasis -from skfda.representation.basis import Monomial -from skfda.exploratory import stats from skfda.datasets import make_multimodal_samples +from skfda.exploratory import stats from skfda.misc.metrics import lp_distance, norm_lp, vectorial_norm +from skfda.representation.basis import Monomial class TestLpMetrics(unittest.TestCase): @@ -19,14 +19,14 @@ def setUp(self): basis = Monomial(nbasis=3, domain_range=(1, 5)) self.fd_basis = FDataBasis(basis, [[1, 1, 0], [0, 0, 1]]) self.fd_curve = self.fd.concatenate(self.fd, as_coordinates=True) - self.fd_surface = make_multimodal_samples(n_samples=3, ndim_domain=2, + self.fd_surface = make_multimodal_samples(n_samples=3, dim_domain=2, random_state=0) def test_vectorial_norm(self): vec = vectorial_norm(self.fd_curve, p=2) np.testing.assert_array_almost_equal(vec.data_matrix, - np.sqrt(2)* self.fd.data_matrix) + np.sqrt(2) * self.fd.data_matrix) vec = vectorial_norm(self.fd_curve, p='inf') np.testing.assert_array_almost_equal(vec.data_matrix, @@ -58,7 +58,7 @@ def test_norm_lp_curve(self): def test_norm_lp_surface_inf(self): np.testing.assert_allclose(norm_lp(self.fd_surface, p='inf').round(5), - [0.99994, 0.99793 , 0.99868]) + [0.99994, 0.99793, 0.99868]) def test_norm_lp_surface(self): # Integration of surfaces not implemented, add test case after @@ -79,7 +79,7 @@ def test_lp_error_dimensions(self): def test_lp_error_domain_ranges(self): sample_points = [2, 3, 4, 5, 6] fd2 = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], - sample_points=sample_points) + sample_points=sample_points) with np.testing.assert_raises(ValueError): lp_distance(self.fd, fd2) @@ -87,7 +87,7 @@ def test_lp_error_domain_ranges(self): def test_lp_error_sample_points(self): sample_points = [1, 2, 4, 4.3, 5] fd2 = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], - sample_points=sample_points) + sample_points=sample_points) with np.testing.assert_raises(ValueError): lp_distance(self.fd, fd2) @@ -103,7 +103,6 @@ def test_lp_grid_basis(self): 0) - if __name__ == '__main__': print() unittest.main() diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index e3bda69a7..354251eba 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -4,21 +4,17 @@ import numpy as np from skfda.datasets import make_multimodal_samples - +from skfda.exploratory.stats import mean as l2_mean +from skfda.misc.metrics import lp_distance, pairwise_distance from skfda.ml.classification import (KNeighborsClassifier, RadiusNeighborsClassifier, NearestCentroids) - +from skfda.ml.clustering import NearestNeighbors from skfda.ml.regression import (KNeighborsScalarRegressor, RadiusNeighborsScalarRegressor, KNeighborsFunctionalRegressor, RadiusNeighborsFunctionalRegressor) - -from skfda.ml.clustering import NearestNeighbors - -from skfda.misc.metrics import lp_distance, pairwise_distance from skfda.representation.basis import Fourier -from skfda.exploratory.stats import mean as l2_mean class TestNeighbors(unittest.TestCase): @@ -294,13 +290,14 @@ def test_score_functional_response(self): y = 5 * self.X + 1 neigh.fit(self.X, y) r = neigh.score(self.X, y) - np.testing.assert_almost_equal(r,0.962651178452408) + np.testing.assert_almost_equal(r, 0.962651178452408) - #Weighted case and basis form + # Weighted case and basis form y = y.to_basis(Fourier(domain_range=y.domain_range[0], nbasis=5)) neigh.fit(self.X, y) - r = neigh.score(self.X[:7], y[:7], sample_weight=4*[1./5]+ 3 *[1./15]) + r = neigh.score(self.X[:7], y[:7], + sample_weight=4 * [1. / 5] + 3 * [1. / 15]) np.testing.assert_almost_equal(r, 0.9982527586114364) def test_score_functional_response_exceptions(self): @@ -308,18 +305,19 @@ def test_score_functional_response_exceptions(self): neigh.fit(self.X, self.X) with np.testing.assert_raises(ValueError): - neigh.score(self.X, self.X, sample_weight=[1,2,3]) + neigh.score(self.X, self.X, sample_weight=[1, 2, 3]) def test_multivariate_response_score(self): neigh = RadiusNeighborsFunctionalRegressor() - y = make_multimodal_samples(n_samples=5, ndim_domain=2, random_state=0) + y = make_multimodal_samples(n_samples=5, dim_domain=2, random_state=0) neigh.fit(self.X[:5], y) # It is not supported the multivariate score by the moment with np.testing.assert_raises(ValueError): neigh.score(self.X[:5], y) + if __name__ == '__main__': print() unittest.main() From 9e4b3c034d794cc3f9089820572a9f7e4fd5ca77 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Fri, 30 Aug 2019 12:54:06 +0200 Subject: [PATCH 195/222] _LinearSmoother documentation typo --- skfda/preprocessing/smoothing/_linear.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skfda/preprocessing/smoothing/_linear.py b/skfda/preprocessing/smoothing/_linear.py index 6f8f672fd..a7882be75 100644 --- a/skfda/preprocessing/smoothing/_linear.py +++ b/skfda/preprocessing/smoothing/_linear.py @@ -98,7 +98,7 @@ def transform(self, X: FDataGrid, y=None): The data to smooth. y : Ignored Returns: - self (object) + FDataGrid: Functional data smoothed. """ @@ -117,7 +117,7 @@ def score(self, X, y): y (FDataGrid): The target data. Typically the same as ``X``. Returns: - self (object) + float: Generalized cross validation score. """ from .validation import LinearSmootherGeneralizedCVScorer From 49f84b9bab78cc6ef08cda4a0dd417a6370e6c75 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 30 Aug 2019 13:26:22 +0200 Subject: [PATCH 196/222] Remove the custom definition of shape for FDataGrid. An FDatagrid shape is now `(n_samples,)` (inherited from Panda's `ExtensionArray`) as a FDataGrid is an array of functions with the same evaluation points. --- .../outliers/_directional_outlyingness.py | 2 +- skfda/ml/clustering/base_kmeans.py | 5 +++-- skfda/representation/grid.py | 12 ------------ 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/skfda/exploratory/outliers/_directional_outlyingness.py b/skfda/exploratory/outliers/_directional_outlyingness.py index 007bb586a..50624bb7f 100644 --- a/skfda/exploratory/outliers/_directional_outlyingness.py +++ b/skfda/exploratory/outliers/_directional_outlyingness.py @@ -170,7 +170,7 @@ def directional_outlyingness_stats( median_index = np.argmax(depth_pointwise, axis=0) pointwise_median = fdatagrid.data_matrix[ median_index, range(fdatagrid.data_matrix.shape[1])] - assert pointwise_median.shape == fdatagrid.shape[1:] + assert pointwise_median.shape == fdatagrid.data_matrix.shape[1:] v = fdatagrid.data_matrix - pointwise_median assert v.shape == fdatagrid.data_matrix.shape v_norm = la.norm(v, axis=-1, keepdims=True) diff --git a/skfda/ml/clustering/base_kmeans.py b/skfda/ml/clustering/base_kmeans.py index cbc5472cd..6ef1efabe 100644 --- a/skfda/ml/clustering/base_kmeans.py +++ b/skfda/ml/clustering/base_kmeans.py @@ -93,7 +93,7 @@ def _generic_clustering_checks(self, fdatagrid): warnings.warn("Warning: The number of iterations is ignored " "because the init parameter is set.") - if self.init is not None and self.init.shape != ( + if self.init is not None and self.init.data_matrix.shape != ( self.n_clusters, fdatagrid.ncol, fdatagrid.dim_codomain): raise ValueError("The init FDataGrid data_matrix should be of " "shape (n_clusters, n_features, dim_codomain) and " @@ -164,7 +164,8 @@ def _check_test_data(self, fdatagrid): """Checks that the FDataGrid object and the calculated centroids have compatible shapes. """ - if fdatagrid.shape[1:3] != self.cluster_centers_.shape[1:3]: + if (fdatagrid.data_matrix.shape[1:3] + != self.cluster_centers_.data_matrix.shape[1:3]): raise ValueError("The fdatagrid shape is not the one expected for " "the calculated cluster_centers_.") diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 8df4478cd..2c8987132 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -343,18 +343,6 @@ def domain_range(self): """ return self._domain_range - @property - def shape(self): - """Dimensions (aka shape) of the data_matrix. - - Returns: - list of int: List containing the length of the matrix on each of - its axis. If the matrix is 2 dimensional shape returns [number of - rows, number of columns]. - - """ - return self.data_matrix.shape - @property def interpolator(self): """Defines the type of interpolation applied in `evaluate`.""" From f2f0f57c0d3a8fa23a0ee73e5c701c5e87cb082a Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 2 Sep 2019 00:20:10 +0200 Subject: [PATCH 197/222] Change visualization to prevent stateful pyplot whenever possible. Examples until extrapolation have been adjusted to the change. --- docs/conf.py | 3 + examples/plot_boxplot.py | 46 +++-- examples/plot_clustering.py | 164 +++++++++--------- examples/plot_composition.py | 60 +++---- examples/plot_discrete_representation.py | 12 +- examples/plot_elastic_registration.py | 63 ++++--- examples/plot_explore.py | 57 +++--- examples/plot_extrapolation.py | 96 +++++----- skfda/_utils/__init__.py | 2 +- skfda/_utils/_utils.py | 25 +++ skfda/exploratory/visualization/_boxplot.py | 25 +-- .../visualization/_magnitude_shape_plot.py | 18 +- .../visualization/clustering_plots.py | 3 +- skfda/misc/covariances.py | 20 +-- skfda/representation/_functional_data.py | 7 +- skfda/representation/basis.py | 5 +- skfda/representation/grid.py | 7 +- 17 files changed, 310 insertions(+), 303 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 7c8c6d3b1..afe208372 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -21,7 +21,9 @@ # import sys # sys.path.insert(0, '/home/miguel/Desktop/fda/fda') +import os import sys + import pkg_resources try: release = pkg_resources.get_distribution('scikit-fda').version @@ -236,3 +238,4 @@ } autosummary_generate = True +os.environ['_SKFDA_USE_PYPLOT'] = '1' diff --git a/examples/plot_boxplot.py b/examples/plot_boxplot.py index cac135b00..0e37b0186 100644 --- a/examples/plot_boxplot.py +++ b/examples/plot_boxplot.py @@ -38,25 +38,24 @@ nlabels = len(label_names) label_colors = colormap(np.arange(nlabels) / (nlabels - 1)) -plt.figure() fd_temperatures.plot(sample_labels=dataset["target"], label_colors=label_colors, label_names=label_names) ############################################################################## -# We instantiate a :func:`functional boxplot object ` -# with the data, and we call its -# :func:`plot function ` to show the graph. +# We instantiate a :class:`~skfda.exploratory.visualization.Boxplot` +# object with the data, and we call its +# :func:`~skfda.exploratory.visualization.Boxplot.plot` function to show the +# graph. # # By default, only the part of the outlier curves which falls out of the # central regions is plotted. We want the entire curve to be shown, that is -# why the show_full_outliers parameter is set to True. +# why the ``show_full_outliers`` parameter is set to True. fdBoxplot = Boxplot(fd_temperatures) fdBoxplot.show_full_outliers = True -plt.figure() fdBoxplot.plot() ############################################################################## @@ -71,7 +70,6 @@ color = 0.3 outliercol = 0.7 -plt.figure() fd_temperatures.plot(sample_labels=fdBoxplot.outliers.astype(int), label_colors=colormap([color, outliercol]), label_names=["nonoutliers", "outliers"]) @@ -79,24 +77,22 @@ ############################################################################## # The curves pointed as outliers are are those curves with significantly lower # values than the rest. This is the expected result due to the depth measure -# used, the :func:`modified band depth -# ` which rank the samples -# according to their magnitude. +# used, :func:`~skfda.exploratory.depth.fraiman_muniz_depth`, which ranks +# the samples according to their magnitude. # -# The :func:`functional boxplot object ` admits any +# The :class:`~skfda.exploratory.visualization.Boxplot` object admits any # :ref:`depth measure ` defined or customized by the user. Now -# the call is done with the :func:`band depth measure -# ` and the factor is reduced -# in order to designate some samples as outliers (otherwise, with this measure -# and the default factor, none of the curves are pointed out as outliers). We -# can see that the outliers detected belong to the Pacific and Arctic climates -# which are less common to find in Canada. As a consequence, this measure -# detects better shape outliers compared to the previous one. +# the call is done with the :func:`~skfda.exploratory.depth.band_depth` and +# the factor is reduced in order to designate some samples as outliers +# (otherwise, with this measure and the default factor, none of the curves are +# pointed out as outliers). We can see that the outliers detected belong to +# the Pacific and Arctic climates which are less common to find in Canada. As +# a consequence, this measure detects better shape outliers compared to the +# previous one. fdBoxplot = Boxplot(fd_temperatures, depth_method=band_depth, factor=0.4) fdBoxplot.show_full_outliers = True -plt.figure() fdBoxplot.plot() ############################################################################## @@ -104,17 +100,15 @@ # boxplot, which can include other central regions, apart from the central or # 50% one. # -# In the following instantiation, the :func:`Fraiman and Muniz depth measure -# ` is used and the 25% and +# In the following instantiation, the +# :func:`~skfda.exploratory.depth.fraiman_muniz_depth` is used and the 25% and # 75% central regions are specified. fdBoxplot = Boxplot(fd_temperatures, depth_method=fraiman_muniz_depth, prob=[0.75, 0.5, 0.25]) -plt.figure() fdBoxplot.plot() ############################################################################## -# The above two lines could be replaced just by fdBoxplot since the default -# representation of the :func:`boxplot object ` is the -# image of the plot. However, due to generation of this notebook it does not -# show the image and that is why the plot method is called. +# The above two lines could be replaced just by fdBoxplot inside a notebook +# since the default representation of the +# :class:`~skfda.exploratory.visualization.Boxplot` is the image of the plot. diff --git a/examples/plot_clustering.py b/examples/plot_clustering.py index cd43aa8b1..b5e0f5e61 100644 --- a/examples/plot_clustering.py +++ b/examples/plot_clustering.py @@ -12,40 +12,42 @@ # sphinx_gallery_thumbnail_number = 6 +import matplotlib.pyplot as plt +import numpy as np from skfda import datasets -from skfda.representation.grid import FDataGrid -from skfda.ml.clustering.base_kmeans import KMeans -from skfda.exploratory.visualization.clustering_plots import * +from skfda.exploratory.visualization.clustering_plots import ( + plot_clusters, plot_cluster_lines, plot_cluster_bars) +from skfda.ml.clustering.base_kmeans import KMeans, FuzzyKMeans -################################################################################## -# First, the Canadian Weather dataset is downloaded from the package 'fda' in CRAN. -# It contains a FDataGrid with daily temperatures and precipitations, that is, it -# has a 2-dimensional image. We are interested only in the daily average temperatures, -# so another FDataGrid is constructed with the desired values. +############################################################################## +# First, the Canadian Weather dataset is downloaded from the package 'fda' in +# CRAN. It contains a FDataGrid with daily temperatures and precipitations, +# that is, it has a 2-dimensional image. We are interested only in the daily +# average temperatures, so we select the first coordinate function. dataset = datasets.fetch_weather() fd = dataset["data"] -fd_temperatures = FDataGrid(data_matrix=fd.data_matrix[:, :, 0], - sample_points=fd.sample_points, - dataset_label=fd.dataset_label, - axes_labels=fd.axes_labels[0:2]) +fd_temperatures = fd.coordinates[0] -# The desired FDataGrid only contains 10 random samples, so that the example provides -# clearer plots. +# The desired FDataGrid only contains 10 random samples, so that the example +# provides clearer plots. indices_samples = np.array([1, 3, 5, 10, 14, 17, 21, 25, 27, 30]) fd = fd_temperatures[indices_samples] -############################################################################################ -# The data is plotted to show the curves we are working with. They are divided according to the -# target. In this case, it includes the different climates to which the weather stations belong to. +############################################################################## +# The data is plotted to show the curves we are working with. They are divided +# according to the target. In this case, it includes the different climates to +# which the weather stations belong to. climate_by_sample = [dataset["target"][i] for i in indices_samples] -# Note that the samples chosen belong to three of the four possible target groups. By -# coincidence, these three groups correspond to indices 1, 2, 3, that is why the indices -# (´climate_by_sample´) are decremented in 1. In case of reproducing the example with other -# ´indices_samples´ and the four groups are not present in the sample, changes should be -# made in order ´indexer´ contains numbers in the interval [0, n_target_groups) and at -# least, an occurrence of each one. + +# Note that the samples chosen belong to three of the four possible target +# groups. By coincidence, these three groups correspond to indices 1, 2, 3, +# that is why the indices (´climate_by_sample´) are decremented in 1. In case +# of reproducing the example with other ´indices_samples´ and the four groups +# are not present in the sample, changes should be made in order ´indexer´ +# contains numbers in the interval [0, n_target_groups) and at least, an +# occurrence of each one. indexer = np.asarray(climate_by_sample) - 1 indices_target_groups = np.unique(climate_by_sample) @@ -56,34 +58,33 @@ n_climates = len(climates) climate_colors = colormap(np.arange(n_climates) / (n_climates - 1)) -plt.figure() -fd.plot(sample_labels=indexer, label_colors=climate_colors, label_names=climates) +fd.plot(sample_labels=indexer, label_colors=climate_colors, + label_names=climates) -############################################################################################ -# The number of clusters is set with the number of climates, in order to see the performance -# of the clustering methods, and the seed is set to one in order to obatain always the same -# result for the example. +############################################################################## +# The number of clusters is set with the number of climates, in order to see +# the performance of the clustering methods, and the seed is set to one in +# order to obatain always the same result for the example. n_clusters = n_climates seed = 2 -############################################################################################ -# First, the class :class:`K-Means ` -# is instantiated with the desired. parameters. Its :func:`fit method -# ` is called , -# resulting in the calculation of several attributes which include among others, -# the the number of cluster each sample belongs to (labels), and the centroids -# of each cluster. The labels are obtaiined calling the method :func:`predict -# ` +############################################################################## +# First, the class :class:`~skfda.ml.clustering.KMeans` is instantiated with +# the desired. parameters. Its :func:`~skfda.ml.clustering.KMeans.fit` method +# is called, resulting in the calculation of several attributes which include +# among others, the the number of cluster each sample belongs to (labels), and +# the centroids of each cluster. The labels are obtaiined calling the method +# :func:`~skfda.ml.clustering.KMeans.predict`. kmeans = KMeans(n_clusters=n_clusters, random_state=seed) kmeans.fit(fd) print(kmeans.predict(fd)) -############################################################################################ -# To see the information in a graphic way, the method :func:`plot_clusters -# ` can be used -# found in the visualization directory. +############################################################################## +# To see the information in a graphic way, the method +# :func:`~skfda.exploratory.visualization.clustering_plots.plot_clusters` can +# be used. # Customization of cluster colors and labels in order to match the first image # of raw data. @@ -93,65 +94,68 @@ plot_clusters(kmeans, fd, cluster_colors=cluster_colors, cluster_labels=cluster_labels) -############################################################################################ +############################################################################## # Other clustering algorithm implemented is the Fuzzy K-Means found in the -# class :class:`FuzzyKMeans `. Following the above -# procedure, an object of this type is instantiated with the desired. data and then, the -# :func:`fit method ` is called. -# Internally, the attribute *labels_* is calculated, which contains ´n_clusters´ -# elements for each sample and dimension, denoting the degree of membership of -# each sample to each cluster. They are obtained calling the method :func:`predict -# `. Also, the centroids of -# each cluster are obtained. +# class :class:`~skfda.ml.clustering.FuzzyKMeans`. Following the +# above procedure, an object of this type is instantiated with the desired +# data and then, the +# :func:`~skfda.ml.clustering.FuzzyKMeans.fit` method is called. +# Internally, the attribute ``labels_`` is calculated, which contains +# ´n_clusters´ elements for each sample and dimension, denoting the degree of +# membership of each sample to each cluster. They are obtained calling the +# method :func:`~skfda.ml.clustering.FuzzyKMeans.predict`. Also, the centroids +# of each cluster are obtained. fuzzy_kmeans = FuzzyKMeans(n_clusters=n_clusters, random_state=seed) fuzzy_kmeans.fit(fd) print(fuzzy_kmeans.predict(fd)) -############################################################################################ -# To see the information in a graphic way, the method :func:`plot_clusters -# ` can be used. -# It assigns each sample to the cluster whose membership value is the greatest. +############################################################################## +# To see the information in a graphic way, the method +# :func:`~skfda.exploratory.visualization.clustering_plots.plot_clusters` can +# be used. It assigns each sample to the cluster whose membership value is the +# greatest. plot_clusters(fuzzy_kmeans, fd, cluster_colors=cluster_colors, cluster_labels=cluster_labels) -############################################################################################ -# Another plot implemented to show the results in the class :class:`Fuzzy K-Means -# ` is the below one, which is similar to parallel coordinates. -# It is recommended to assign colors to each of the samples in order to identify them. In this -# example, the colors are the ones of the first plot, dividing the samples by climate. +############################################################################## +# Another plot implemented to show the results in the class +# :class:`~skfda.ml.clustering.FuzzyKMeans` is +# :func:`~skfda.exploratory.visualization.clustering_plots.plot_cluster_lines` +# which is similar to parallel coordinates. It is recommended to assign colors +# to each of the samples in order to identify them. In this example, the +# colors are the ones of the first plot, dividing the samples by climate. colors_by_climate = colormap(indexer / (n_climates - 1)) -plt.figure() plot_cluster_lines(fuzzy_kmeans, fd, cluster_labels=cluster_labels, sample_colors=colors_by_climate) -############################################################################################ -# Lastly, the function :func:`plot_cluster_bars -# ` -# found in the module :mod:`clustering_plots -# `, -# returns a barplot. Each sample is designated with a bar which is filled proportionally -# to the membership values with the color of each cluster. +############################################################################## +# Finally, the function +# :func:`~skfda.exploratory.visualization.clustering_plots.plot_cluster_bars` +# returns a barplot. Each sample is designated with a bar which is filled +# proportionally to the membership values with the color of each cluster. -plt.figure() plot_cluster_bars(fuzzy_kmeans, fd, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) - -############################################################################################ -# The possibility of sorting the bars according to a cluster is given specifying the number of -# cluster, which belongs to the interval [0, n_clusters). - -plt.figure() + cluster_labels=cluster_labels) + +############################################################################## +# The possibility of sorting the bars according to a cluster is given +# specifying the number of cluster, which belongs to the interval +# [0, n_clusters). +# +# We can order the data using the first cluster: plot_cluster_bars(fuzzy_kmeans, fd, sort=0, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) + cluster_labels=cluster_labels) -plt.figure() +############################################################################## +# Using the second cluster: plot_cluster_bars(fuzzy_kmeans, fd, sort=1, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) + cluster_labels=cluster_labels) -plt.figure() +############################################################################## +# And using the third cluster: plot_cluster_bars(fuzzy_kmeans, fd, sort=2, cluster_colors=cluster_colors, - cluster_labels=cluster_labels) + cluster_labels=cluster_labels) diff --git a/examples/plot_composition.py b/examples/plot_composition.py index 8bf090ed5..b390bfc70 100644 --- a/examples/plot_composition.py +++ b/examples/plot_composition.py @@ -10,57 +10,54 @@ # sphinx_gallery_thumbnail_number = 3 -import skfda -import matplotlib.pyplot as plt -import numpy as np - from mpl_toolkits.mplot3d import axes3d +import numpy as np +import skfda - -############################################################################### +############################################################################## # Function composition can be applied to our data once is in functional -# form using the method :func:`compose`. +# form using the method :func:`~skfda.representation.FData.compose`. # -# Let :math:`f: X \rightarrow Y` and :math:`g: Y \rightarrow Z`, the composition -# will produce a third function :math:`g \circ f: X \rightarrow Z` which maps -# :math:`x \in X` to :math:`g(f(x))` [1]. +# Let :math:`f: X \rightarrow Y` and :math:`g: Y \rightarrow Z`, the +# composition will produce a third function :math:`g \circ f: X \rightarrow Z` +# which maps :math:`x \in X` to :math:`g(f(x))` [1]. # -# In `Landmark Registration `_ it is shown the -# simplest case, where it is used to apply a transformation of the time scale of -# unidimensional data to register its features. +# In :ref:`sphx_glr_auto_examples_plot_landmark_registration.py` it is shown +# the simplest case, where it is used to apply a transformation of the time +# scale of unidimensional data to register its features. # -# The following example shows the basic usage applied to a surface and a curve, -# although the method will work for data with arbitrary dimensions to. +# The following example shows the basic usage applied to a surface and a +# curve, although the method will work for data with arbitrary dimensions to. # # Firstly we will create a data object containing a surface # :math:`g: \mathbb{R}^2 \rightarrow \mathbb{R}`. # - # Constructs example surface X, Y, Z = axes3d.get_test_data(1.2) data_matrix = [Z.T] -sample_points = [X[0,:], Y[:, 0]] +sample_points = [X[0, :], Y[:, 0]] g = skfda.FDataGrid(data_matrix, sample_points) # Sets cubic interpolation -g.interpolator = skfda.representation.interpolation.SplineInterpolator(interpolation_order=3) +g.interpolator = skfda.representation.interpolation.SplineInterpolator( + interpolation_order=3) # Plots the surface g.plot() -############################################################################### -# We will create a parametric curve :math:`f(t)=(10 \, \cos(t), 10 \, sin(t))`. -# The result of the composition, :math:`g \circ f:\mathbb{R} \rightarrow -# \mathbb{R}` -# will be another functional object with the values of :math:`g` along the path -# given by :math:`f`. +############################################################################## +# We will create a parametric curve +# :math:`f(t)=(10 \, \cos(t), 10 \, sin(t))`. The result of the composition, +# :math:`g \circ f:\mathbb{R} \rightarrow \mathbb{R}` will be another +# functional object with the values of :math:`g` along the path given by +# :math:`f`. # # Creation of circunference in parametric form -t = np.linspace(0, 2*np.pi, 100) +t = np.linspace(0, 2 * np.pi, 100) data_matrix = [10 * np.array([np.cos(t), np.sin(t)]).T] f = skfda.FDataGrid(data_matrix, t) @@ -68,24 +65,23 @@ # Composition of function gof = g.compose(f) -plt.figure() - gof.plot() -############################################################################### +############################################################################## # In the following chart it is plotted the curve # :math:`(10 \, \cos(t), 10 \, sin(t), g \circ f (t))` and the surface. # # Plots surface -fig, ax = g.plot(alpha=.8) +fig = g.plot(alpha=.8) # Plots path along the surface path = f(t)[0] -ax[0].plot(path[:,0], path[:,1], gof(t)[0], color="orange") +fig.axes[0].plot(path[:, 0], path[:, 1], gof(t)[0], color="orange") + +fig -plt.show() -############################################################################### +############################################################################## # [1] Function composition `https://en.wikipedia.org/wiki/Function_composition # `_. # diff --git a/examples/plot_discrete_representation.py b/examples/plot_discrete_representation.py index 0f35d26fd..143eb4644 100644 --- a/examples/plot_discrete_representation.py +++ b/examples/plot_discrete_representation.py @@ -10,20 +10,20 @@ # sphinx_gallery_thumbnail_number = 2 -from skfda import FDataGrid import numpy as np +from skfda import FDataGrid -############################################################################### + +############################################################################## # We will construct a dataset containing several sinusoidal functions with # random displacements. - random_state = np.random.RandomState(0) sample_points = np.linspace(0, 1) data = np.array([np.sin((sample_points + random_state.randn()) * 2 * np.pi) for _ in range(5)]) -############################################################################### +############################################################################## # The FDataGrid class is used for datasets containing discretized functions # that are measured at the same points. @@ -33,12 +33,12 @@ fd = fd[:5] -############################################################################### +############################################################################## # We can plot the measured values of each function in a scatter plot. fd.scatter(s=0.5) -############################################################################### +############################################################################## # We can also plot the interpolated functions. fd.plot() diff --git a/examples/plot_elastic_registration.py b/examples/plot_elastic_registration.py index bcee83d36..222c6be65 100644 --- a/examples/plot_elastic_registration.py +++ b/examples/plot_elastic_registration.py @@ -10,68 +10,65 @@ # sphinx_gallery_thumbnail_number = 5 - -import skfda -import matplotlib.pyplot as plt import numpy as np +import skfda -############################################################################### +############################################################################## # In the example of pairwise alignment was shown the usage of -# :func:`elastic_registration ` to align +# :func:`~skfda.preprocessing.registration.elastic_registration` to align # a set of functional observations to a given template or a set of templates. # -# In the groupwise alignment all the samples are aligned to the same templated, +# In the groupwise alignment all the samples are aligned to the same template, # constructed to minimise some distance, generally a mean or a median. In the # case of the elastic registration, due to the use of the elastic distance in # the alignment, one of the most suitable templates is the karcher mean under # this metric. # -# We will create a synthetic dataset to show the basic usage of the registration. +# We will create a synthetic dataset to show the basic usage of the +# registration. # - - fd = skfda.datasets.make_multimodal_samples(n_modes=2, stop=4, random_state=1) fd.plot() ############################################################################### # The following figure shows the -# :func:`elastic mean ` of the dataset and the -# cross-sectional mean, which correspond to the karcher-mean under the -# :math:`\mathbb{L}^2` distance. +# :func:`~skfda.preprocessing.registration.elastic_mean` of the +# dataset and the cross-sectional mean, which correspond to the karcher-mean +# under the :math:`\mathbb{L}^2` distance. # -# It can be seen how the elastic mean better captures the geometry of the curves -# compared to the standard mean, since it is not affected by the deformations of -# the curves. +# It can be seen how the elastic mean better captures the geometry of the +# curves compared to the standard mean, since it is not affected by the +# deformations of the curves. +fig = fd.mean().plot(label="L2 mean") +skfda.preprocessing.registration.elastic_mean( + fd).plot(fig=fig, label="Elastic mean") +fig.legend() +fig -plt.figure() -fd.mean().plot(label="L2 mean") -skfda.preprocessing.registration.elastic_mean(fd).plot(label="Elastic mean") -plt.legend() - -############################################################################### +############################################################################## # In this case, the alignment completely reduces the amplitude variability # between the samples, aligning the maximum points correctly. fd_align = skfda.preprocessing.registration.elastic_registration(fd) -plt.figure() fd_align.plot() -############################################################################### +############################################################################## # In general these type of alignments are not possible, in the following # figure it is shown how it works with a real dataset. # The :func:`berkeley growth dataset` -# contains the growth curves of a set children, in this case will be used only the -# males. The growth curves will be resampled using cubic interpolation and derived -# to obtain the velocity curves. +# contains the growth curves of a set children, in this case will be used only +# the males. The growth curves will be resampled using cubic interpolation and +# derived to obtain the velocity curves. # +# First we show the original curves: growth = skfda.datasets.fetch_growth() -# Select only one sex +# Select only one sex fd = growth['data'][growth['target'] == 0] # Obtain velocity curves @@ -80,19 +77,19 @@ fd = fd.to_grid(np.linspace(*fd.domain_range[0], 50)) fd.plot() -plt.figure() +############################################################################## +# We now show the aligned curves: + fd_align = skfda.preprocessing.registration.elastic_registration(fd) fd_align.dataset_label += " - aligned" fd_align.plot() -plt.show() - -############################################################################### +############################################################################## # * Srivastava, Anuj & Klassen, Eric P. (2016). Functional and shape data # analysis. In *Functional Data and Elastic Registration* (pp. 73-122). # Springer. # -# * J. S. Marron, James O. Ramsay, Laura M. Sangalli and Anuj Srivastava (2015). -# Functional Data Analysis of Amplitude and Phase Variation. +# * J. S. Marron, James O. Ramsay, Laura M. Sangalli and Anuj Srivastava +# (2015). Functional Data Analysis of Amplitude and Phase Variation. # Statistical Science 2015, Vol. 30, No. 4 diff --git a/examples/plot_explore.py b/examples/plot_explore.py index 84dda5271..0b86c21db 100644 --- a/examples/plot_explore.py +++ b/examples/plot_explore.py @@ -9,15 +9,15 @@ # Author: Miguel Carbajo Berrocal # License: MIT -import skfda -import matplotlib.pyplot as plt import numpy as np +import skfda + -############################################################################### +############################################################################## # In this example we are going to explore the functional properties of the -# :func:`Tecator ` dataset. This dataset measures -# the infrared absorbance spectrum of meat samples. The objective is to predict -# the fat, water, and protein content of the samples. +# :func:`Tecator ` dataset. This dataset +# measures the infrared absorbance spectrum of meat samples. The objective is +# to predict the fat, water, and protein content of the samples. # # In this example we only want to discriminate between meat with less than 20% # of fat, and meat with a higher fat content. @@ -27,35 +27,44 @@ target_feature_names = dataset['target_feature_names'] fat = y[:, np.asarray(target_feature_names) == 'Fat'].ravel() -############################################################################### +############################################################################## # We will now plot in red samples containing less than 20% of fat and in blue # the rest. low_fat = fat < 20 +labels = np.zeros(fd.n_samples, dtype=int) +labels[low_fat] = 1 +colors = ['red', 'blue'] -fd[low_fat].plot(c='r', linewidth=0.5) -fd[~low_fat].plot(c='b', linewidth=0.5, alpha=0.7) +fig = fd.plot(sample_labels=labels, label_colors=colors, + linewidth=0.5, alpha=0.7) -############################################################################### +############################################################################## # The means of each group are the following ones. -skfda.exploratory.stats.mean(fd[low_fat]).plot(c='r', - linewidth=0.5) -skfda.exploratory.stats.mean(fd[~low_fat]).plot(c='b', - linewidth=0.5, alpha=0.7) -fd.dataset_label = fd.dataset_label + ' - means' +mean_low = skfda.exploratory.stats.mean(fd[low_fat]) +mean_high = skfda.exploratory.stats.mean(fd[~low_fat]) -############################################################################### -# In this dataset, the vertical shift in the original trajectories is not very -# significative for predicting the fat content. However, the shape of the curve -# is very relevant. We can observe that looking at the first and second +means = mean_high.concatenate(mean_low) + +means.dataset_label = fd.dataset_label + ' - means' +means.plot(sample_labels=[0, 1], label_colors=colors, + linewidth=0.5) + +############################################################################## +# In this dataset, the vertical shift in the original trajectories is not +# very significative for predicting the fat content. However, the shape of the +# curve is very relevant. We can observe that looking at the first and second # derivatives. +# +# The first derivative is shown below: fdd = fd.derivative(1) -fdd[low_fat].plot(c='r', linewidth=0.5) -fdd[~low_fat].plot(c='b', linewidth=0.5, alpha=0.7) +fig = fdd.plot(sample_labels=labels, label_colors=colors, + linewidth=0.5, alpha=0.7) -plt.figure() +############################################################################## +# We now show the second derivative: fdd = fd.derivative(2) -fdd[low_fat].plot(c='r', linewidth=0.5) -fdd[~low_fat].plot(c='b', linewidth=0.5, alpha=0.7) +fig = fdd.plot(sample_labels=labels, label_colors=colors, + linewidth=0.5, alpha=0.7) diff --git a/examples/plot_extrapolation.py b/examples/plot_extrapolation.py index 1d64ff71f..4b508f353 100644 --- a/examples/plot_extrapolation.py +++ b/examples/plot_extrapolation.py @@ -10,34 +10,37 @@ # sphinx_gallery_thumbnail_number = 2 -import skfda -import numpy as np -import matplotlib.pyplot as plt import mpl_toolkits.mplot3d -############################################################################### +import matplotlib.pyplot as plt +import numpy as np +import skfda + + +############################################################################## # # The extrapolation defines how to evaluate points that are # outside the domain range of a -# :class:`FDataBasis ` or a -# :class:`FDataGrid `. +# :class:`~skfda.representation.basis.FDataBasis` or a +# :class:`~skfda.representation.grid.FDataGrid`. # -# The :class:`FDataBasis ` objects have a -# predefined extrapolation which is applied in ´evaluate´ -# if the argument `extrapolation` is not supplied. This default value +# The :class:`~skfda.representation.basis.FDataBasis` objects have a +# predefined extrapolation which is applied in +# :class:`~skfda.representation.basis.FDataBasis.evaluate` +# if the argument ``extrapolation`` is not supplied. This default value # could be specified when the object is created or changing the -# attribute `extrapolation`. +# attribute ``extrapolation``. # # The extrapolation could be specified by a string with the short name of an # extrapolator or with an -# :class:´Extrapolator ´. +# :class:´~skfda.representation.extrapolation.Extrapolator´. # # To show how it works we will create a dataset with two unidimensional curves -# defined in (0,1), and we will represent it using a grid and different types of -# basis. +# defined in (0,1), and we will represent it using a grid and different types +# of basis. # - -fdgrid = skfda.datasets.make_sinusoidal_process(n_samples=2, error_std=0, random_state=0) +fdgrid = skfda.datasets.make_sinusoidal_process( + n_samples=2, error_std=0, random_state=0) fdgrid.dataset_label = "Grid" fd_fourier = fdgrid.to_basis(skfda.representation.basis.Fourier()) @@ -51,7 +54,7 @@ # Plot of diferent representations -fig, ax = plt.subplots(2,2) +fig, ax = plt.subplots(2, 2) fdgrid.plot(ax[0][0]) fd_fourier.plot(ax[0][1]) fd_monomial.plot(ax[1][0]) @@ -65,7 +68,7 @@ fdgrid.dataset_label = "" -############################################################################### +############################################################################## # # If the extrapolation is not specified when a list of points is evaluated and # the default extrapolation of the objects has not been specified it is used @@ -79,20 +82,20 @@ domain_extended = (-0.2, 1.2) -fig, ax = plt.subplots(2,2) +fig, ax = plt.subplots(2, 2) # Plot objects in the domain range extended fdgrid.plot(ax[0][0], domain_range=domain_extended, linestyle='--') -fd_fourier.plot(ax[0][1],domain_range=domain_extended, linestyle='--') +fd_fourier.plot(ax[0][1], domain_range=domain_extended, linestyle='--') fd_monomial.plot(ax[1][0], domain_range=domain_extended, linestyle='--') fd_bspline.plot(ax[1][1], domain_range=domain_extended, linestyle='--') # Plot configuration for axes in fig.axes: axes.set_prop_cycle(None) - axes.set_ylim((-1.5,1.5)) - axes.set_xlim((-0.25,1.25)) + axes.set_ylim((-1.5, 1.5)) + axes.set_xlim((-0.25, 1.25)) # Disable xticks of first row ax[0][0].set_xticks([]) @@ -105,7 +108,7 @@ fd_bspline.plot(ax[1][1]) -############################################################################### +############################################################################## # # Periodic extrapolation will extend the domain range periodically. # The following example shows the periodical extension of an FDataGrid. @@ -116,7 +119,7 @@ # t = np.linspace(*domain_extended) -plt.figure() +fig = plt.figure() fdgrid.dataset_label = "Periodic extrapolation" # Evaluation of the grid @@ -125,18 +128,18 @@ plt.plot(t, values.T, linestyle='--') -plt.gca().set_prop_cycle(None) # Reset color cycle +plt.gca().set_prop_cycle(None) # Reset color cycle -fdgrid.plot() # Plot dataset +fdgrid.plot(fig=fig) # Plot dataset -############################################################################### +############################################################################## # -# Another possible extrapolation, "bounds", will use the values of the interval -# bounds for points outside the domain range. +# Another possible extrapolation, ``"bounds"``, will use the values of the +# interval bounds for points outside the domain range. # -plt.figure() +fig = plt.figure() fdgrid.dataset_label = "Boundary extrapolation" # Other way to call the extrapolation, changing the default value @@ -146,43 +149,41 @@ values = fdgrid(t) plt.plot(t, values.T, linestyle='--') -plt.gca().set_prop_cycle(None) # Reset color cycle +plt.gca().set_prop_cycle(None) # Reset color cycle -fdgrid.plot() # Plot dataset +fdgrid.plot(fig=fig) # Plot dataset -############################################################################### +############################################################################## # -# The :class:´FillExtrapolation ´ will fill +# The :class:`~skfda.representation.extrapolation.FillExtrapolation` will fill # the points extrapolated with the same value. The case of filling with zeros -# could be specified with the string `"zeros"`, which is equivalent to -# `extrapolation=FillExtrapolation(0)`. +# could be specified with the string ``"zeros"``, which is equivalent to +# ``extrapolation=FillExtrapolation(0)``. # - -plt.figure() fdgrid.dataset_label = "Fill with zeros" # Evaluation of the grid filling with zeros fdgrid.extrapolation = "zeros" # Plot in domain extended -fdgrid.plot(domain_range=domain_extended, linestyle='--') +fig = fdgrid.plot(domain_range=domain_extended, linestyle='--') -plt.gca().set_prop_cycle(None) # Reset color cycle +plt.gca().set_prop_cycle(None) # Reset color cycle -fdgrid.plot() # Plot dataset +fdgrid.plot(fig=fig) # Plot dataset -############################################################################### +############################################################################## # -# The string "nan" is equivalent to `FillExtrapolation(np.nan)`. +# The string ``"nan"`` is equivalent to ``FillExtrapolation(np.nan)``. # values = fdgrid([-1, 0, 0.5, 1, 2], extrapolation="nan") print(values) -############################################################################### +############################################################################## # # It is possible to configure the extrapolation to raise an exception in case # of evaluating a point outside the domain. @@ -190,11 +191,10 @@ try: res = fd_fourier(t, extrapolation="exception") - except ValueError as e: print(e) -############################################################################### +############################################################################## # # All the extrapolators shown will work with multidimensional objects. # In the following example it is constructed a 2d-surface and it is extended @@ -214,7 +214,7 @@ t = np.arange(-7, 7.5, 0.5) # Evaluation with periodic extrapolation -values = fd_surface((t,t), grid=True, extrapolation="periodic") +values = fd_surface((t, t), grid=True, extrapolation="periodic") T, S = np.meshgrid(t, t) @@ -227,7 +227,7 @@ # of the bounds. -values = fd_surface((t,t), grid=True, extrapolation="bounds") +values = fd_surface((t, t), grid=True, extrapolation="bounds") fig = plt.figure() ax = fig.add_subplot(111, projection='3d') @@ -239,7 +239,7 @@ # Or filling the surface with zeros outside the domain. -values = fd_surface((t,t), grid=True, extrapolation="zeros") +values = fd_surface((t, t), grid=True, extrapolation="zeros") fig = plt.figure() ax = fig.add_subplot(111, projection='3d') diff --git a/skfda/_utils/__init__.py b/skfda/_utils/__init__.py index 0847f78a9..a29cc56a1 100644 --- a/skfda/_utils/__init__.py +++ b/skfda/_utils/__init__.py @@ -2,4 +2,4 @@ from ._utils import (_list_of_arrays, _coordinate_list, _check_estimator, parameter_aliases, - _figure_to_svg) + _create_figure, _figure_to_svg) diff --git a/skfda/_utils/_utils.py b/skfda/_utils/_utils.py index 1c5f7c43e..ba0a1d061 100644 --- a/skfda/_utils/_utils.py +++ b/skfda/_utils/_utils.py @@ -2,10 +2,12 @@ import functools import io +import os import types import matplotlib.backends.backend_svg +import matplotlib.pyplot as plt import numpy as np @@ -142,7 +144,30 @@ def _check_estimator(estimator): check_set_params(name, instance) +def _create_figure(): + """Create figure using the default backend.""" + + if '_SKFDA_USE_PYPLOT' in os.environ: + use_pyplot = os.environ['_SKFDA_USE_PYPLOT'] == '1' + else: + use_pyplot = False + + if use_pyplot: + fig = plt.figure() + else: + fig = matplotlib.figure.Figure() + + # Get the default backend + backend = plt.new_figure_manager.__self__ + + backend.new_figure_manager_given_figure(1, fig) + + return fig + + def _figure_to_svg(figure): + """Return the SVG representation of a figure.""" + old_canvas = figure.canvas matplotlib.backends.backend_svg.FigureCanvas(figure) output = io.BytesIO() diff --git a/skfda/exploratory/visualization/_boxplot.py b/skfda/exploratory/visualization/_boxplot.py index 7a76f3630..fa6c4fb57 100644 --- a/skfda/exploratory/visualization/_boxplot.py +++ b/skfda/exploratory/visualization/_boxplot.py @@ -14,6 +14,7 @@ import numpy as np from ... import FDataGrid +from ..._utils import _create_figure, _figure_to_svg from ..depth import modified_band_depth from ..outliers import _envelopes @@ -76,13 +77,8 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): pass def _repr_svg_(self): - plt.figure() - fig, _ = self.plot() - output = BytesIO() - fig.savefig(output, format='svg') - data = output.getvalue() - plt.close(fig) - return data.decode('utf-8') + fig = self.plot() + return _figure_to_svg(fig) class Boxplot(FDataBoxplot): @@ -331,11 +327,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): specified if fig and ax are None. Returns: - (tuple): tuple containing: - - * fig (figure): figure object in which the graphs are plotted. - * ax (list): axes in which the graphs are plotted. - + fig (figure): figure object in which the graphs are plotted. """ @@ -400,7 +392,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): self.fdatagrid.set_labels(fig, ax) - return fig, ax + return fig def __repr__(self): """Return repr(self).""" @@ -609,10 +601,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): specified if fig and ax are None. Returns: - (tuple): tuple containing: - - * fig (figure): figure object in which the graphs are plotted. - * ax (list): axes in which the graphs are plotted. + fig (figure): figure object in which the graphs are plotted. """ fig, ax = self.fdatagrid.generic_plotting_checks(fig, ax, nrows, @@ -700,7 +689,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): self.fdatagrid.set_labels(fig, ax) - return fig, ax + return fig def __repr__(self): """Return repr(self).""" diff --git a/skfda/exploratory/visualization/_magnitude_shape_plot.py b/skfda/exploratory/visualization/_magnitude_shape_plot.py index 3d5319555..fde6fa435 100644 --- a/skfda/exploratory/visualization/_magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/_magnitude_shape_plot.py @@ -14,8 +14,9 @@ import matplotlib.pyplot as plt import numpy as np -from skfda.exploratory.depth import modified_band_depth +from ..._utils import _create_figure, _figure_to_svg +from ..depth import modified_band_depth from ..outliers import (directional_outlyingness_stats, DirectionalOutlierDetector) @@ -249,7 +250,6 @@ def plot(self, ax=None): Returns: fig (figure object): figure object in which the graph is plotted. - ax (axes object): axes in which the graph is plotted. """ colors = np.zeros((self.fdatagrid.n_samples, 4)) @@ -257,7 +257,8 @@ def plot(self, ax=None): colors[np.where(self.outliers == 0)] = self.colormap(self.color) if ax is None: - ax = matplotlib.pyplot.gca() + fig = _create_figure() + ax = fig.add_subplot(1, 1, 1) colors_rgba = [tuple(i) for i in colors] ax.scatter(self.points[:, 0].ravel(), self.points[:, 1].ravel(), @@ -267,7 +268,7 @@ def plot(self, ax=None): ax.set_ylabel(self.ylabel) ax.set_title(self.title) - return ax.get_figure(), ax + return ax.get_figure() def __repr__(self): """Return repr(self).""" @@ -286,10 +287,5 @@ def __repr__(self): f"\ntitle={repr(self.title)})").replace('\n', '\n ') def _repr_svg_(self): - plt.figure() - fig, _ = self.plot() - output = BytesIO() - fig.savefig(output, format='svg') - data = output.getvalue() - plt.close(fig) - return data.decode('utf-8') + fig = self.plot() + return _figure_to_svg(fig) diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering_plots.py index ba6931486..ac9368397 100644 --- a/skfda/exploratory/visualization/clustering_plots.py +++ b/skfda/exploratory/visualization/clustering_plots.py @@ -10,6 +10,7 @@ import matplotlib.pyplot as plt import numpy as np +from ..._utils import _create_figure from ...ml.clustering.base_kmeans import FuzzyKMeans @@ -361,7 +362,7 @@ def _fig_and_ax_checks(fig, ax): "the graph is going to be shown.") if fig is None and ax is None: - fig = plt.gcf() + fig = _create_figure() if ax is None: axes = fig.get_axes() diff --git a/skfda/misc/covariances.py b/skfda/misc/covariances.py index 27bebd4a5..0b5c1136e 100644 --- a/skfda/misc/covariances.py +++ b/skfda/misc/covariances.py @@ -6,7 +6,7 @@ import numpy as np import sklearn.gaussian_process.kernels as sklearn_kern -from .._utils import _figure_to_svg +from .._utils import _create_figure, _figure_to_svg def _squared_norms(x, y): @@ -58,20 +58,20 @@ def heatmap(self): cov_matrix = self(x, x) - fig = matplotlib.figure.Figure() + fig = _create_figure() ax = fig.add_subplot(1, 1, 1) ax.imshow(cov_matrix, extent=[-1, 1, 1, -1]) ax.set_title("Covariance function in [-1, 1]") - return fig, ax + return fig def _sample_trajectories_plot(self): from ..datasets import make_gaussian_process fd = make_gaussian_process(start=-1, cov=self) - fig, ax = fd.plot() - ax[0].set_title("Sample trajectories") - return fig, ax + fig = fd.plot() + fig.axes[0].set_title("Sample trajectories") + return fig def __repr__(self): @@ -96,16 +96,16 @@ def _repr_latex_(self): return fr"\(\displaystyle {self._latex_content()}\)" def _repr_html_(self): - fig, _ = self.heatmap() + fig = self.heatmap() heatmap = _figure_to_svg(fig) - fig, _ = self._sample_trajectories_plot() + fig = self._sample_trajectories_plot() sample_trajectories = _figure_to_svg(fig) - row_style = 'style="display: flex; display:table-row"' + row_style = 'style="position:relative; display:table-row"' def column_style(percent): - return (f'style="flex: {percent}%; display: table-cell; ' + return (f'style="width: {percent}%; display: table-cell; ' f'vertical-align: middle"') html = f""" diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 315a2492e..a08839c6f 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -14,7 +14,7 @@ import matplotlib.pyplot as plt import numpy as np -from .._utils import _coordinate_list, _list_of_arrays, constants +from .._utils import _coordinate_list, _list_of_arrays, constants, _create_figure from .extrapolation import _parse_extrapolation @@ -632,7 +632,7 @@ def set_figure_and_axes(self, nrows, ncols): elif ncols is not None and nrows is None: nrows = int(np.ceil(self.dim_codomain / ncols)) - fig = plt.gcf() + fig = _create_figure() axes = fig.get_axes() # If it is not empty @@ -888,7 +888,6 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, Returns: fig (figure object): figure object in which the graphs are plotted. - ax (axes object): axes in which the graphs are plotted. """ @@ -1011,7 +1010,7 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, self.set_labels(fig, ax, patches) - return fig, ax + return fig @abstractmethod def copy(self, **kwargs): diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index 80e51a004..e9fec1847 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -171,10 +171,7 @@ def plot(self, chart=None, *, derivative=0, **kwargs): fdata.plot function. Returns: - (tuple): tuple containing: - - * fig (figure): figure object in which the graphs are plotted. - * ax (list): axes in which the graphs are plotted. + fig (figure): figure object in which the graphs are plotted. """ self.to_basis().plot(chart=chart, derivative=derivative, **kwargs) diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 2c8987132..29b9f9071 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -801,10 +801,7 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): matplotlib.pyplot.scatter function; Returns: - (tuple): tuple containing: - - * fig (figure): figure object in which the graphs are plotted. - * ax (list): axes in which the graphs are plotted. + fig (figure): figure object in which the graphs are plotted. """ @@ -826,7 +823,7 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): self.set_labels(fig, ax) - return fig, ax + return fig def to_basis(self, basis, **kwargs): """Return the basis representation of the object. From 9b1ef039f3f8985ab7a8a1eb1d44a865fc5aba1e Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 2 Sep 2019 16:49:13 +0200 Subject: [PATCH 198/222] Fixed all examples. --- examples/plot_interpolation.py | 105 ++++++------ examples/plot_k_neighbors_classification.py | 101 +++++------ examples/plot_kernel_smoothing.py | 84 +++++----- examples/plot_landmark_registration.py | 76 ++++----- examples/plot_landmark_shift.py | 83 +++++---- examples/plot_magnitude_shape.py | 29 ++-- examples/plot_magnitude_shape_synthetic.py | 20 +-- .../plot_neighbors_functional_regression.py | 73 ++++---- examples/plot_neighbors_scalar_regression.py | 91 +++++----- examples/plot_pairwise_alignment.py | 157 +++++++++--------- .../plot_radius_neighbors_classification.py | 104 ++++++------ examples/plot_representation.py | 47 +++--- examples/plot_shift_registration_basis.py | 51 +++--- examples/plot_surface_boxplot.py | 28 ++-- skfda/representation/_functional_data.py | 6 +- 15 files changed, 530 insertions(+), 525 deletions(-) diff --git a/examples/plot_interpolation.py b/examples/plot_interpolation.py index 727b18e3e..686c3f627 100644 --- a/examples/plot_interpolation.py +++ b/examples/plot_interpolation.py @@ -19,47 +19,47 @@ from skfda.representation.interpolation import SplineInterpolator -############################################################################### -# The :class:`FDataGrid` class is used for datasets containing discretized -# functions. For the evaluation between the points of discretization, or sample -# points, is necessary to interpolate. +############################################################################## +# The :class:`~skfda.representation.grid.FDataGrid` class is used for datasets +# containing discretized functions. For the evaluation between the points of +# discretization, or sample points, is necessary to interpolate. # # We will construct an example dataset with two curves with 6 points of # discretization. # fd = skfda.datasets.make_sinusoidal_process(n_samples=2, n_features=6, random_state=1) -fd.scatter() -plt.legend(["Sample 1", "Sample 2"]) +fig = fd.scatter() +fig.legend(["Sample 1", "Sample 2"]) -############################################################################### +############################################################################## # By default it is used linear interpolation, which is one of the simplest # methods of interpolation and therefore one of the least computationally -# expensive, but has the disadvantage that the interpolant is not differentiable -# at the points of discretization. +# expensive, but has the disadvantage that the interpolant is not +# differentiable at the points of discretization. # -fd.plot() -fd.scatter() +fig = fd.plot() +fd.scatter(fig=fig) -########################################################################## +############################################################################## # The interpolation method of the FDataGrid could be changed setting the -# attribute `interpolator`. Once we have set an interpolator it is used for +# attribute ``interpolator``. Once we have set an interpolator it is used for # the evaluation of the object. # # Polynomial spline interpolation could be performed using the interpolator -# :class:`SplineInterpolator`. In the following example a cubic interpolator -# is set. +# :class:`~skfda.representation.interpolation.SplineInterpolator`. In the +# following example a cubic interpolator is set. fd.interpolator = SplineInterpolator(interpolation_order=3) -fd.plot() -fd.scatter() +fig = fd.plot() +fd.scatter(fig=fig) -############################################################################### +############################################################################## # Smooth interpolation could be performed with the attribute -# `smoothness_parameter` of the spline interpolator. +# ``smoothness_parameter`` of the spline interpolator. # # Sample with noise @@ -69,19 +69,19 @@ # Cubic interpolator fd_smooth.interpolator = SplineInterpolator(interpolation_order=3) -fd_smooth.plot(label="Cubic") +fig = fd_smooth.plot(label="Cubic") # Smooth interpolation fd_smooth.interpolator = SplineInterpolator(interpolation_order=3, smoothness_parameter=1.5) -fd_smooth.plot(label="Cubic smoothed") +fd_smooth.plot(fig=fig, label="Cubic smoothed") -fd_smooth.scatter() -plt.legend() +fd_smooth.scatter(fig=fig) +fig.legend() -############################################################################### +############################################################################## # It is possible to evaluate derivatives of the FDatagrid, # but due to the fact that interpolation is performed first, the interpolation # loses one degree for each order of derivation. In the next example, it is @@ -91,48 +91,53 @@ fd = fd[1] +fig = plt.figure() +fig.add_subplot(1, 1, 1) + for i in range(1, 4): fd.interpolator = SplineInterpolator(interpolation_order=i) - fd.plot(derivative=1, label=f"Degree {i}") + fd.plot(fig=fig, derivative=1, label=f"Degree {i}") -plt.legend() +fig.legend() -############################################################################### +############################################################################## # FDataGrids can be differentiate using lagged differences with the -# method :func:`derivative`, creating another FDataGrid which could be -# interpolated in order to avoid interpolating before differentiating. +# method :func:`~skfda.representation.grid.FDataGrid.derivative`, creating +# another FDataGrid which could be interpolated in order to avoid +# interpolating before differentiating. # fd_derivative = fd.derivative() -fd_derivative.plot(label="Differentiation first") -fd_derivative.scatter() +fig = fd_derivative.plot(label="Differentiation first") +fd_derivative.scatter(fig=fig) -fd.plot(derivative=1, label="Interpolation first") +fd.plot(fig=fig, derivative=1, label="Interpolation first") -plt.legend() +fig.legend() -############################################################################### +############################################################################## # Sometimes our samples are required to be monotone, in these cases it is -# possible to use monotone cubic interpolation with the attribute `monotone`. -# A piecewise cubic hermite interpolating polynomial (PCHIP) will be used. +# possible to use monotone cubic interpolation with the attribute +# ``monotone``. A piecewise cubic hermite interpolating polynomial (PCHIP) +# will be used. # fd_monotone = fd.copy(data_matrix=np.sort(fd.data_matrix, axis=1)) -fd_monotone.plot(linestyle='--', label="cubic") +fig = fd_monotone.plot(linestyle='--', label="cubic") fd_monotone.interpolator = SplineInterpolator(interpolation_order=3, monotone=True) -fd_monotone.plot(label="PCHIP") +fd_monotone.plot(fig=fig, label="PCHIP") -fd_monotone.scatter(c='C1') -plt.legend() +fd_monotone.scatter(fig=fig, c='C1') +fig.legend() -############################################################################### +############################################################################## # All the interpolators will work regardless of the dimension of the image, but # depending on the domain dimension some methods will not be available. # @@ -148,10 +153,10 @@ fd = skfda.FDataGrid(data_matrix, sample_points) -fig, ax = fd.plot() -fd.scatter(ax=ax) +fig = fd.plot() +fd.scatter(fig=fig) -############################################################################### +############################################################################## # In the following figure it is shown the result of the cubic interpolation # applied to the surface. # @@ -164,12 +169,10 @@ fd.interpolator = SplineInterpolator(interpolation_order=3) -fig, ax = fd.plot() -fd.scatter(ax=ax) - -plt.show() +fig = fd.plot() +fd.scatter(fig=fig) -############################################################################### +############################################################################## # In case of surface derivatives could be taked in two directions, for this # reason a tuple with the order of derivates in each direction could be passed. # Let :math:`x(t,s)` be the surface, in the following example it is shown the @@ -178,9 +181,7 @@ fd.plot(derivative=(0, 1)) -plt.show() - -############################################################################### +############################################################################## # The following table shows the interpolation methods available by the class # :class:`SplineInterpolator` depending on the domain dimension. # diff --git a/examples/plot_k_neighbors_classification.py b/examples/plot_k_neighbors_classification.py index 494cff4e2..1d9912de1 100644 --- a/examples/plot_k_neighbors_classification.py +++ b/examples/plot_k_neighbors_classification.py @@ -8,15 +8,16 @@ # Author: Pablo Marcos Manchón # License: MIT -import skfda -import numpy as np -import matplotlib.pyplot as plt from sklearn.model_selection import (train_test_split, GridSearchCV, StratifiedShuffleSplit) + +import matplotlib.pyplot as plt +import numpy as np +import skfda from skfda.ml.classification import KNeighborsClassifier -################################################################################ +############################################################################## # # In this example we are going to show the usage of the K-nearest neighbors # classifier in their functional version, which is a extension of the @@ -29,7 +30,6 @@ # # The following figure shows the growth curves grouped by sex. # - # Loads dataset data = skfda.datasets.fetch_growth() X = data['data'] @@ -37,11 +37,10 @@ class_names = data['target_names'] # Plot samples grouped by sex -plt.figure() X.plot(sample_labels=y, label_names=class_names, label_colors=['C0', 'C1']) -################################################################################ +############################################################################## # # In this case, the class labels are stored in an array with 0's in the male # samples and 1's in the positions with female ones. @@ -49,35 +48,35 @@ print(y) -################################################################################ +############################################################################## # # We can split the dataset using the sklearn function -# :func:`train_test_split `. +# :func:`~sklearn.model_selection.train_test_split`. # -# The function will return two :class:`FDataGrid `'s, -# ``X_train`` and ``X_test`` with the corresponding partitions, and arrays -# with their class labels. +# The function will return two +# :class:`~skfda.representation.grid.FDataGrid`'s, ``X_train`` and ``X_test`` +# with the corresponding partitions, and arrays with their class labels. # X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, stratify=y, random_state=0) -################################################################################ +############################################################################## # # We will fit the classifier -# :class:`KNeighborsClassifier ` +# :class:`~skfda.ml.classification.KNeighborsClassifier` # with the training partition. This classifier works exactly like the sklearn # multivariate classifier -# :class:`KNeighborsClassifier ` , but -# will accept as input a :class:`FDataGrid` with functional observations instead -# of an array with multivariate data. +# :class:`~sklearn.neighbors.KNeighborsClassifier`, but +# will accept as input a :class:`~skfda.representation.grid.FDataGrid` with +# functional observations instead of an array with multivariate data. # knn = KNeighborsClassifier() knn.fit(X_train, y_train) -################################################################################ +############################################################################## # # Once it is fitted, we can predict labels for the test samples. # @@ -85,36 +84,39 @@ # k-nearest neighbors and will asign the majority class. By default, it is # used the :math:`\mathbb{L}^2` distance between functions, to determine the # neighbourhood of a sample, with 5 neighbors. -# Can be used any of the functional metrics of the module -# :mod:`skfda.misc.metrics`. +# Can be used any of the functional metrics described in +# :doc:`/modules/misc/metrics`. # pred = knn.predict(X_test) print(pred) -################################################################################ +############################################################################## # -# The :func:`score` method allows us to calculate the mean accuracy for the test -# data. In this case we obtained around 96% of accuracy. +# The :func:`~skfda.ml.classification.KNeighborsClassifier.score` method +# allows us to calculate the mean accuracy for the test data. In this case we +# obtained around 96% of accuracy. # score = knn.score(X_test, y_test) print(score) -################################################################################ +############################################################################## # # We can also estimate the probability of membership to the predicted class -# using :func:`predict_proba`, which will return an array with the -# probabilities of the classes, in lexicographic order, for each test sample. +# using :func:`~skfda.ml.classification.KNeighborsClassifier.predict_proba`, +# which will return an array with the probabilities of the classes, in +# lexicographic order, for each test sample. +# probs = knn.predict_proba(X_test[:5]) # Predict first 5 samples print(probs) -################################################################################ +############################################################################## # # We can use the sklearn -# :func:`GridSearchCV ` to perform a +# :class:`~sklearn.model_selection.GridSearchCV` to perform a # grid search to select the best hyperparams, using cross-validation. # # In this case, we will vary the number of neighbors between 1 and 11. @@ -136,29 +138,30 @@ print("Best score:", gscv.best_score_) -################################################################################ +############################################################################## # -# We have obtained the greatest mean accuracy using 11 neighbors. The following -# figure shows the score depending on the number of neighbors. +# We have obtained the greatest mean accuracy using 11 neighbors. The +# following figure shows the score depending on the number of neighbors. # -plt.figure() -plt.bar(param_grid['n_neighbors'], gscv.cv_results_['mean_test_score']) -plt.xticks(param_grid['n_neighbors']) -plt.ylabel("Number of Neighbors") -plt.xlabel("Test score") -plt.ylim((0.9, 1)) +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) +ax.bar(param_grid['n_neighbors'], gscv.cv_results_['mean_test_score']) +ax.set_xticks(param_grid['n_neighbors']) +ax.set_ylabel("Number of Neighbors") +ax.set_xlabel("Test score") +ax.set_ylim((0.9, 1)) -################################################################################ +############################################################################## # -# When the functional data have been sampled in an equiespaced way, or -# approximately equiespaced, it is possible to use the scikit-learn vector +# When the functional data have been sampled in an equispaced way, or +# approximately equispaced, it is possible to use the scikit-learn vector # metrics with similar results. # # For example, in the case of the :math:`\mathbb{L}^2` distance, # if the integral of the distance it is approximated as a -# Riemann sum, we obtain that it is proporitonal to the euclidean +# Riemann sum, we obtain that it is proportional to the euclidean # distance between vectors. # # .. math:: @@ -168,13 +171,13 @@ # = \sqrt{\bigtriangleup h} \, d_{euclidean}(\vec{f}, \vec{g}) # # -# So, in this case, it is roughtly equivalent to use this metric instead of the -# functional one, due to the constant multiplication do no affect the +# So, in this case, it is roughly equivalent to use this metric instead of the +# functional one, due to the constant multiplication not affecting the # order of the neighbors. # -# Setting the parameter ``sklearn_metric`` of the classifier to True, +# Setting the parameter ``sklearn_metric`` of the classifier to ``True``, # a vectorial metric of sklearn can be passed. In -# :class:`sklearn.neighbors.DistanceMetric` there are listed all the metrics +# :class:`~sklearn.neighbors.DistanceMetric` there are listed all the metrics # supported. # # We will fit the model with the sklearn distance and search for the best @@ -189,11 +192,11 @@ print("Best params:", gscv2.best_params_) print("Best score:", gscv2.best_score_) -################################################################################ +############################################################################## # # The advantage of use the sklearn metrics is the computational speed, three # orders of magnitude faster. But it is not always possible to have -# equiespaced samples nor do all functional metrics have a vector equivalent +# equispaced samples nor do all functional metrics have a vector equivalent # in this way. # # The mean score time depending on the metric is shown below. @@ -205,10 +208,8 @@ print("Euclidean distance:", 1000 * np.mean(gscv2.cv_results_['mean_score_time']), "(ms)") -################################################################################ +############################################################################## # # This classifier can be used with multivariate funcional data, as surfaces # or curves in :math:`\mathbb{R}^N`, if the metric support it too. # - -plt.show() diff --git a/examples/plot_kernel_smoothing.py b/examples/plot_kernel_smoothing.py index bdaff55db..16ba7dcb4 100644 --- a/examples/plot_kernel_smoothing.py +++ b/examples/plot_kernel_smoothing.py @@ -11,13 +11,14 @@ # Author: Miguel Carbajo Berrocal # License: MIT +import matplotlib.pylab as plt +import numpy as np import skfda import skfda.preprocessing.smoothing.kernel_smoothers as ks import skfda.preprocessing.smoothing.validation as val -import matplotlib.pylab as plt -import numpy as np -############################################################################### + +############################################################################## # # For this example, we will use the # :func:`phoneme ` dataset. This dataset @@ -27,13 +28,12 @@ # # As an example, we will smooth the first 300 curves only. In the following # plot, the first five curves are shown. - dataset = skfda.datasets.fetch_phoneme() fd = dataset['data'][:300] fd[0:5].plot() -############################################################################### +############################################################################## # Here we show the general cross validation scores for different values of the # parameters given to the different smoothing methods. @@ -57,67 +57,75 @@ knn.fit(fd) knn_fd = knn.transform(fd) -plt.plot(param_values, knn.cv_results_['mean_test_score'], - label='k-nearest neighbors') -plt.plot(param_values, llr.cv_results_['mean_test_score'], - label='local linear regression') -plt.plot(param_values, nw.cv_results_['mean_test_score'], - label='Nadaraya-Watson') -plt.legend() - -############################################################################### -# We can plot the smoothed curves corresponding to the 11th element of the data -# set (this is a random choice) for the three different smoothing methods. - -ax = plt.gca() +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) +ax.plot(param_values, knn.cv_results_['mean_test_score'], + label='k-nearest neighbors') +ax.plot(param_values, llr.cv_results_['mean_test_score'], + label='local linear regression') +ax.plot(param_values, nw.cv_results_['mean_test_score'], + label='Nadaraya-Watson') +ax.legend() +fig + +############################################################################## +# We can plot the smoothed curves corresponding to the 11th element of the +# data set (this is a random choice) for the three different smoothing +# methods. + +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) ax.set_xlabel('Smoothing method parameter') ax.set_ylabel('GCV score') ax.set_title('Scores through GCV for different smoothing methods') -ax.legend(['k-nearest neighbors', 'local linear regression', - 'Nadaraya-Watson'], - title='Smoothing method') -fd[10].plot() -knn_fd[10].plot() -llr_fd[10].plot() -nw_fd[10].plot() -ax = plt.gca() +fd[10].plot(fig=fig) +knn_fd[10].plot(fig=fig) +llr_fd[10].plot(fig=fig) +nw_fd[10].plot(fig=fig) ax.legend(['original data', 'k-nearest neighbors', 'local linear regression', 'Nadaraya-Watson'], title='Smoothing method') +fig -############################################################################### +############################################################################## # We can compare the curve before and after the smoothing. +############################################################################## # Not smoothed + fd[10].plot() +############################################################################## # Smoothed -plt.figure() -fd[10].scatter(s=0.5) -nw_fd[10].plot(c='g') -############################################################################### +fig = fd[10].scatter(s=0.5) +nw_fd[10].plot(fig=fig, color='green') +fig + +############################################################################## # Now, we can see the effects of a proper smoothing. We can plot the same 5 # samples from the beginning using the Nadaraya-Watson kernel smoother with # the best choice of parameter. -plt.figure(4) nw_fd[0:5].plot() -############################################################################### +############################################################################## # We can also appreciate the effects of undersmoothing and oversmoothing in # the following plots. fd_us = ks.NadarayaWatsonSmoother(smoothing_parameter=2).fit_transform(fd[10]) fd_os = ks.NadarayaWatsonSmoother(smoothing_parameter=15).fit_transform(fd[10]) +############################################################################## # Under-smoothed -fd[10].scatter(s=0.5) -fd_us.plot() +fig = fd[10].scatter(s=0.5) +fd_us.plot(fig=fig) + +############################################################################## # Over-smoothed -plt.figure() -fd[10].scatter(s=0.5) -fd_os.plot() + +fig = fd[10].scatter(s=0.5) +fd_os.plot(fig=fig) diff --git a/examples/plot_landmark_registration.py b/examples/plot_landmark_registration.py index 1618fda67..3f754b7c1 100644 --- a/examples/plot_landmark_registration.py +++ b/examples/plot_landmark_registration.py @@ -13,7 +13,7 @@ import skfda -############################################################################### +############################################################################## # The simplest curve alignment procedure is landmark registration. This # method only takes into account a discrete ammount of features of the curves # which will be registered. @@ -23,29 +23,28 @@ # minima, or zero crossings of curves, and may be identified at the level of # some derivatives as well as at the level of the curves themselves. # We align the curves by transforming t for each curve so that landmark -# locations are the same for all curves. [1][2] +# locations are the same for all curves ( [RaSi2005]_ , [RaHoGr2009]_ ). # # We will use a dataset synthetically generated by -# :func:`make_multimodal_samples `, wich -# in this case will be used to generate bimodal curves. +# :func:`~skfda.datasets.make_multimodal_samples`, which in this case will +# be used to generate bimodal curves. # fd = skfda.datasets.make_multimodal_samples(n_samples=4, n_modes=2, std=.002, mode_std=.005, random_state=1) fd.plot() -############################################################################### +############################################################################## # For this type of alignment we need to know in advance the location of the # landmarks of each of the samples, in our case it will correspond to the two -# maximun points of each sample. +# maximum points of each sample. # Because our dataset has been generated synthetically we can obtain the value # of the landmarks using the function -# :func:`make_multimodal_landmarks `, -# which is used by -# :func:`make_multimodal_samples ` to -# set the location of the modes. +# :func:`~skfda.datasets.make_multimodal_landmarks`, which is used by +# :func:`~skfda.datasets.make_multimodal_samples` to set the location of the +# modes. # -# In general it will be necessary to use numerical or other methods to determine -# the location of the landmarks. +# In general it will be necessary to use numerical or other methods to +# determine the location of the landmarks. # landmarks = skfda.datasets.make_multimodal_landmarks(n_samples=4, n_modes=2, @@ -54,18 +53,18 @@ print(landmarks) -############################################################################### +############################################################################## # The transformation will not be linear, and will be the result of # applying a warping function to the time of our curves. # -# After the identification of the landmarks asociated with the features of each -# of our curves we can construct the warping function with the function -# :func:`landmark_registration_warping -# `. +# After the identification of the landmarks asociated with the features of +# each of our curves we can construct the warping function with the function +# :func:`~skfda.preprocessing.registration.landmark_registration_warping`. # # Let :math:`h_i` be the warping function corresponding with the curve -# :math:`i`, :math:`t_{ij}` the time where the curve :math:`i` has their feature -# :math:`j` and :math:`t^*_j` the new location of the feature :math:`j`. +# :math:`i`, :math:`t_{ij}` the time where the curve :math:`i` has their +# feature :math:`j` and :math:`t^*_j` the new location of the feature +# :math:`j`. # The warping functions will transform the new time in the original time of # the curve, i.e., :math:`h_i(t^*_j) = t_{ij}`. These functions # will be defined between landmarks using monotone cubic interpolation (see @@ -74,34 +73,36 @@ # In this case we will place the landmarks at -0.5 and 0.5. # -warping = skfda.preprocessing.registration.landmark_registration_warping(fd, landmarks, - location=[-0.5, 0.5]) - -plt.figure() +warping = skfda.preprocessing.registration.landmark_registration_warping( + fd, landmarks, location=[-0.5, 0.5]) # Plots warping -warping.plot() +fig = warping.plot() # Plot landmarks for i in range(fd.n_samples): - plt.scatter([-0.5, 0.5], landmarks[i]) + fig.axes[0].scatter([-0.5, 0.5], landmarks[i]) + +fig -############################################################################### +############################################################################## # -# Once we have the warping functions, the registered curves can be obtained using -# function composition. Let :math:`x_i` a curve, we can obtain the +# Once we have the warping functions, the registered curves can be obtained +# using function composition. Let :math:`x_i` a curve, we can obtain the # corresponding registered curve as :math:`x^*_i(t) = x_i(h_i(t))`. fd_registered = fd.compose(warping) -fd_registered.plot() +fig = fd_registered.plot() + +fig.axes[0].scatter([-0.5, 0.5], [1, 1]) -plt.scatter([-0.5, 0.5], [1, 1]) +fig -############################################################################### +############################################################################## # # If we do not need the warping function we can obtain the registered curves -# directly using the function :func:`landmark_registration -# `. +# directly using the function +# :func:`~skfda.preprocessing.registration.landmark_registration`. # # If the position of the new location of the landmarks is not specified the # mean position is taken. @@ -116,8 +117,9 @@ plt.show() -############################################################################### -# [1] Ramsay, J., Silverman, B. W. (2005). Functional Data Analysis. Springer. +############################################################################## +# .. [RaSi2005] Ramsay, J., Silverman, B. W. (2005). Functional Data Analysis. +# Springer. # -# [2] Ramsay, J., Hooker, G. & Graves S. (2009). Functional Data Analysis with -# R and Matlab. Springer. +# .. [RaHoGr2009] Ramsay, J., Hooker, G. & Graves S. (2009). Functional Data Analysis +# with R and Matlab. Springer. diff --git a/examples/plot_landmark_shift.py b/examples/plot_landmark_shift.py index e102a25fd..c38961244 100644 --- a/examples/plot_landmark_shift.py +++ b/examples/plot_landmark_shift.py @@ -17,51 +17,51 @@ import skfda -############################################################################### +############################################################################## # We will use an example dataset synthetically generated by -# :func:`make_multimodal_samples `, wich -# in this case will be used to generate gaussian-like samples with a mode near -# to 0. +# :func:`~skfda.datasets.make_multimodal_samples`, which in this case will be +# used to generate gaussian-like samples with a mode near to 0. # Each sample will be shifted to align their modes to a reference point using -# the function :func:`landmark_shift `. +# the function :func:`~skfda.preprocessing.registration.landmark_shift`. # fd = skfda.datasets.make_multimodal_samples(random_state=1) fd.extrapolation = 'bounds' #  See extrapolation for a detailed explanation. fd.plot() -############################################################################### +############################################################################## # A landmark or a feature of a curve is some characteristic that one can # associate with a specific argument value t. These are typically maxima, # minima, or zero crossings of curves, and may be identified at the level of -# some derivatives as well as at the level of the curves themselves. [1] +# some derivatives as well as at the level of the curves themselves +# [RaSi2005]_. # -# For alignment we need to know in advance the location of the landmark of each -# of the samples, in our case it will correspond to the maxima of each sample. -# Because our dataset has been generated synthetically we can obtain the value -# of the landmarks using the function -# :func:`make_multimodal_landmarks `, -# which is used by -# :func:`make_multimodal_samples ` to -# set the location of the modes. +# For alignment we need to know in advance the location of the landmark of +# each of the samples, in our case it will correspond to the maxima of each +# sample. Because our dataset has been generated synthetically we can obtain +# the value of the landmarks using the function +# :func:`~skfda.datasets.make_multimodal_landmarks`, which is used by +# :func:`~skfda.datasets.make_multimodal_samples` to set the location of the +# modes. # -# In general it will be necessary to use numerical or other methods to determine -# the location of the landmarks. +# In general it will be necessary to use numerical or other methods to +# determine the location of the landmarks. # landmarks = skfda.datasets.make_multimodal_landmarks(random_state=1).squeeze() -plt.figure() -plt.scatter(landmarks, np.repeat(1, fd.n_samples)) -fd.plot() +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) +ax.scatter(landmarks, np.repeat(1, fd.n_samples)) +fd.plot(fig=fig) -############################################################################### +############################################################################## # Location of the landmarks: # print(landmarks) -############################################################################### +############################################################################## # The following figure shows the result of shifting the curves to align their # landmarks at 0. # @@ -69,37 +69,35 @@ fd_registered = skfda.preprocessing.registration.landmark_shift( fd, landmarks, location=0) -plt.figure() -fd_registered.plot() -plt.scatter(0, 1) +fig = fd_registered.plot() +fig.axes[0].scatter(0, 1) -############################################################################### +############################################################################## # In many circumstances it is possible that we could not apply extrapolation, # in these cases it is possible to restrict the domain to avoid evaluating # points outside where our curves are defined. # -# If the location of the new reference point is not specified it is choosen the -# point that minimizes the maximun amount of shift. +# If the location of the new reference point is not specified it is choosen +# the point that minimizes the maximum amount of shift. # # Curves aligned restricting the domain -fd_restricted = skfda.preprocessing.registration.landmark_shift(fd, landmarks, - restrict_domain=True) +fd_restricted = skfda.preprocessing.registration.landmark_shift( + fd, landmarks, restrict_domain=True) -# Curves aligned to default point without restrict domain +# Curves aligned to default point without restrict domain fd_extrapolated = skfda.preprocessing.registration.landmark_shift( fd, landmarks) -plt.figure() -l1 = fd_extrapolated.plot(linestyle='dashed', label='Extrapolated samples') -l2 = fd_restricted.plot(label="Restricted samples") -#plt.legend(handles=[l1[-1], l2[-1]]) +fig = fd_extrapolated.plot(linestyle='dashed', label='Extrapolated samples') +fd_restricted.plot(fig=fig, label="Restricted samples") -############################################################################### +############################################################################## # The previous method is also applicable for multidimensional objects, # without limitation of the domain or image dimension. As an example we are -# going to create a datset with surfaces, in a similar way to the previous case. +# going to create a datset with surfaces, in a similar way to the previous +# case. # fd = skfda.datasets.make_multimodal_samples(n_samples=3, points_per_dim=30, @@ -107,15 +105,15 @@ fd.plot() -############################################################################### +############################################################################## # In this case the landmarks will be defined by tuples with 2 coordinates. # -landmarks = skfda.datasets.make_multimodal_landmarks(n_samples=3, dim_domain=2, - random_state=1).squeeze() +landmarks = skfda.datasets.make_multimodal_landmarks( + n_samples=3, dim_domain=2, random_state=1).squeeze() print(landmarks) -############################################################################### +############################################################################## # As in the previous case, we can align the curves to a specific point, # or by default will be chosen the point that minimizes the maximum amount # of displacement. @@ -128,5 +126,6 @@ plt.show() ############################################################################### -# [1] Ramsay, J., Silverman, B. W. (2005). Functional Data Analysis. Springer. +# .. [RaSi2005] Ramsay, J., Silverman, B. W. (2005). Functional Data Analysis. +# Springer. # diff --git a/examples/plot_magnitude_shape.py b/examples/plot_magnitude_shape.py index 0975cc6ce..15c791c61 100644 --- a/examples/plot_magnitude_shape.py +++ b/examples/plot_magnitude_shape.py @@ -37,14 +37,13 @@ nlabels = len(label_names) label_colors = colormap(np.arange(nlabels) / (nlabels - 1)) -plt.figure() fd_temperatures.plot(sample_labels=dataset["target"], label_colors=label_colors, label_names=label_names) ############################################################################## # The MS-Plot is generated. In order to show the results, the -# :func:`plot method ` +# :func:`~skfda.exploratory.visualization.MagnitudeShapePlot.plot` method # is used. Note that the colors have been specified before to distinguish # between outliers or not. In particular the tones of the default colormap, # (which is 'seismic' and can be customized), are assigned. @@ -55,7 +54,6 @@ color = 0.3 outliercol = 0.7 -plt.figure() msplot.color = color msplot.outliercol = outliercol msplot.plot() @@ -64,7 +62,6 @@ # To show the utility of the plot, the curves are plotted according to the # distinction made by the MS-Plot (outliers or not) with the same colors. -plt.figure() fd_temperatures.plot(sample_labels=msplot.outliers.astype(int), label_colors=msplot.colormap([color, outliercol]), label_names=['nonoutliers', 'outliers']) @@ -79,20 +76,19 @@ # outliers but in the MS-Plot, they appear further left from the central # points. This behaviour can be modified specifying the parameter alpha. # -# Now we use the -# :func:`Fraiman and Muniz depth measure ` -# in the MS-Plot. +# Now we use the pointwise +# :func:`~skfda.exploratory.depth_measures.fraiman_muniz_depth` in the +# MS-Plot. msplot = MagnitudeShapePlot(fdatagrid=fd_temperatures, depth_method=fraiman_muniz_depth) -plt.figure() msplot.color = color msplot.outliercol = outliercol msplot.plot() ############################################################################## -# We can observe that none of the samples are pointed as outliers. +# We can observe that almost none of the samples are pointed as outliers. # Nevertheless, if we group them in three groups according to their position # in the MS-Plot, the result is the expected one. Those samples at the left # (larger deviation in the mean directional outlyingness) correspond to the @@ -108,16 +104,19 @@ colors[group1] = outliercol colors[group2] = 0.9 -plt.figure() -plt.scatter(msplot.points[:, 0], msplot.points[:, 1], c=colormap(colors)) -plt.title("MS-Plot") -plt.xlabel("magnitude outlyingness") -plt.ylabel("shape outlyingness") +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) +ax.scatter(msplot.points[:, 0], msplot.points[:, 1], c=colormap(colors)) +ax.set_title("MS-Plot") +ax.set_xlabel("magnitude outlyingness") +ax.set_ylabel("shape outlyingness") labels = np.copy(msplot.outliers.astype(int)) labels[group1] = 1 labels[group2] = 2 -plt.figure() +############################################################################## +# We now plot the curves with their corresponding color: + fd_temperatures.plot(sample_labels=labels, label_colors=colormap([color, outliercol, 0.9])) diff --git a/examples/plot_magnitude_shape_synthetic.py b/examples/plot_magnitude_shape_synthetic.py index 530a90891..7f2b18725 100644 --- a/examples/plot_magnitude_shape_synthetic.py +++ b/examples/plot_magnitude_shape_synthetic.py @@ -76,18 +76,16 @@ # The data is plotted to show the curves we are working with. labels = [0] * n_samples + [1] * 6 -plt.figure() fd.plot(sample_labels=labels, label_colors=['lightgrey', 'black']) ############################################################################## # The MS-Plot is generated. In order to show the results, the -# :func:`plot method ` -# is used. +# :func:`~skfda.exploratory.visualization.MagnitudeShapePlot.plot` +# method is used. msplot = MagnitudeShapePlot(fdatagrid=fd) -plt.figure() msplot.plot() ############################################################################## @@ -98,19 +96,19 @@ colors = ['lightgrey', 'orange', 'blue', 'black', 'green', 'brown', 'lightblue'] -plt.figure() fd.plot(sample_labels=labels, label_colors=colors) ############################################################################## # We now show the points in the MS-plot using the same colors -plt.figure() -plt.scatter(msplot.points[:, 0].ravel(), msplot.points[:, 1].ravel(), - c=colors[0:1] * n_samples + colors[1:]) -plt.title("MS-Plot") -plt.xlabel("magnitude outlyingness") -plt.ylabel("shape outlyingness") +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) +ax.scatter(msplot.points[:, 0].ravel(), msplot.points[:, 1].ravel(), + c=colors[0:1] * n_samples + colors[1:]) +ax.set_title("MS-Plot") +ax.set_xlabel("magnitude outlyingness") +ax.set_ylabel("shape outlyingness") ############################################################################## # .. rubric:: References diff --git a/examples/plot_neighbors_functional_regression.py b/examples/plot_neighbors_functional_regression.py index 079981871..e5aac9285 100644 --- a/examples/plot_neighbors_functional_regression.py +++ b/examples/plot_neighbors_functional_regression.py @@ -10,31 +10,30 @@ # sphinx_gallery_thumbnail_number = 4 -import skfda -import matplotlib.pyplot as plt -import numpy as np from sklearn.model_selection import train_test_split + +import matplotlib.pyplot as plt +import skfda from skfda.ml.regression import KNeighborsFunctionalRegressor from skfda.representation.basis import Fourier -################################################################################ +############################################################################## # # In this example we are going to show the usage of the nearest neighbors # regressors with functional response. There is available a K-nn version, -# :class:`KNeighborsFunctionalRegressor -# `, and other one based in -# the radius, :class:`RadiusNeighborsFunctionalRegressor -# `. +# :class:`~skfda.ml.regression.KNeighborsFunctionalRegressor`, and other one +# based in the radius, +# :class:`~skfda.ml.regression.RadiusNeighborsFunctionalRegressor`. # # -# As in the scalar response example, we will fetch the caniadian weather -# dataset, which contains the daily temperature and +# As in the :ref:`scalar response example +# `, we will fetch +# the Canadian weather dataset, which contains the daily temperature and # precipitation at 35 different locations in Canada averaged over 1960 to 1994. # The following figure shows the different temperature and precipitation # curves. # - data = skfda.datasets.fetch_weather() fd = data['data'] @@ -42,36 +41,38 @@ # Split dataset, temperatures and curves of precipitation X, y = fd.coordinates -plt.figure() +############################################################################## +# Temperatures + X.plot() -plt.figure() +############################################################################## +# Precipitation + y.plot() -################################################################################ +############################################################################## # -# We will try to predict the precipitation curves. First of all we are going to -# make a smoothing of the precipitation curves using a basis representation, -# employing for it a fourier basis with 5 elements. +# We will try to predict the precipitation curves. First of all we are going +# to make a smoothing of the precipitation curves using a basis +# representation, employing for it a fourier basis with 5 elements. # - y = y.to_basis(Fourier(nbasis=5)) -plt.figure() y.plot() - -################################################################################ +############################################################################## # # We will split the dataset in two partitions, for training and test, -# using the sklearn function :func:`sklearn.model_selection.train_test_split`. +# using the sklearn function +# :func:`~sklearn.model_selection.train_test_split`. # X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=.1, random_state=28) -################################################################################ +############################################################################## # # We will try make a prediction using 5 neighbors and the :math:`\mathbb{L}^2` # distance. In this case, to calculate @@ -83,9 +84,10 @@ knn = KNeighborsFunctionalRegressor(n_neighbors=5, weights='distance') knn.fit(X_train, y_train) -################################################################################ +############################################################################## # -# We can predict values for the test partition using :meth:`predict`. The +# We can predict values for the test partition using +# :meth:`~skfda.ml.regression.KNeighborsFunctionalRegressor.predict`. The # following figure shows the real precipitation curves, in dashed line, and # the predicted ones. # @@ -93,16 +95,17 @@ y_pred = knn.predict(X_test) # Plot prediction -plt.figure() -fig, ax = y_pred.plot() -ax[0].set_prop_cycle(None) # Reset colors -y_test.plot(linestyle='--') +fig = y_pred.plot() +fig.axes[0].set_prop_cycle(None) # Reset colors +y_test.plot(fig=fig, linestyle='--') -################################################################################ +############################################################################## # # We can quantify how much variability it is explained by the model -# using the :meth:`score` method, which computes the value +# using the +# :meth:`~skfda.ml.regression.KNeighborsFunctionalRegressor.score` method, +# which computes the value # # .. math:: # 1 - \frac{\sum_{i=1}^{n}\int (y_i(t) - \hat{y}_i(t))^2dt} @@ -114,7 +117,7 @@ score = knn.score(X_test, y_test) print(score) -################################################################################ +########################################################################## # # More detailed information about the canadian weather dataset can be obtained # in the following references. @@ -122,8 +125,6 @@ # * Ramsay, James O., and Silverman, Bernard W. (2006). Functional Data # Analysis, 2nd ed. , Springer, New York. # -# * Ramsay, James O., and Silverman, Bernard W. (2002). Applied Functional -# Data Analysis, Springer, New York\n' +# * Ramsay, James O., and Silverman, Bernard W. (2002). Applied Functional +# Data Analysis, Springer, New York\n' # - -plt.show() diff --git a/examples/plot_neighbors_scalar_regression.py b/examples/plot_neighbors_scalar_regression.py index 8558c79e5..7c232d6eb 100644 --- a/examples/plot_neighbors_scalar_regression.py +++ b/examples/plot_neighbors_scalar_regression.py @@ -10,31 +10,31 @@ # sphinx_gallery_thumbnail_number = 3 -import skfda +from sklearn.model_selection import train_test_split, GridSearchCV, KFold + import matplotlib.pyplot as plt import numpy as np -from sklearn.model_selection import train_test_split, GridSearchCV, KFold +import skfda from skfda.ml.regression import KNeighborsScalarRegressor -################################################################################ +############################################################################## # # In this example, we are going to show the usage of the nearest neighbors # regressors with scalar response. There is available a K-nn version, -# :class:`KNeighborsScalarRegressor -# `, and other one based in the -# radius, :class:`RadiusNeighborsScalarRegressor -# `. +# :class:`~skfda.ml.regression.KNeighborsScalarRegressor`, and other one based +# in the radius, +# :class:`~skfda.ml.regression.RadiusNeighborsScalarRegressor`. # # Firstly we will fetch a dataset to show the basic usage. # -# The caniadian weather dataset contains the daily temperature and -# precipitation at 35 different locations in Canada averaged over 1960 to 1994. +# The Canadian weather dataset contains the daily temperature and +# precipitation at 35 different locations in Canada averaged over 1960 to +# 1994. # # The following figure shows the different temperature and precipitation # curves. # - data = skfda.datasets.fetch_weather() fd = data['data'] @@ -42,13 +42,17 @@ # Split dataset, temperatures and curves of precipitation X, y_func = fd.coordinates -plt.figure() +############################################################################## +# Temperatures + X.plot() -plt.figure() +############################################################################## +# Precipitation + y_func.plot() -################################################################################ +############################################################################## # # We will try to predict the total log precipitation, i.e, # :math:`logPrecTot_i = \log \sum_{t=0}^{365} prec_i(t)` using the temperature @@ -61,60 +65,61 @@ print(log_prec) -################################################################################ +############################################################################## # -# As in the nearest neighbors classifier examples, we will split the dataset in -# two partitions, for training and test, using the sklearn function -# :func:`sklearn.model_selection.train_test_split`. +# As in the nearest neighbors classifier examples, we will split the dataset +# in two partitions, for training and test, using the sklearn function +# :func:`~sklearn.model_selection.train_test_split`. # X_train, X_test, y_train, y_test = train_test_split(X, log_prec, random_state=7) -################################################################################ +############################################################################## # # Firstly we will try make a prediction with the default values of the # estimator, using 5 neighbors and the :math:`\mathbb{L}^2` distance. # -# We can fit the :class:`KNeighborsScalarRegressor -# ` in the same way than the -# sklearn estimators. This estimator is an extension of the sklearn -# :class:`sklearn.neighbors.KNeighborsRegressor`, but accepting a -# :class:`FDataGrid ` as input instead of an array with -# multivariate data. +# We can fit the :class:`~skfda.ml.regression.KNeighborsScalarRegressor` in +# the same way than the scikit-learn estimators. This estimator is an +# extension of the sklearn :class:`~sklearn.neighbors.KNeighborsRegressor`, +# but accepting a :class:`~skfda.representation.grid.FDataGrid` as input +# instead of an array with multivariate data. # knn = KNeighborsScalarRegressor(weights='distance') knn.fit(X_train, y_train) -################################################################################ +############################################################################## # -# We can predict values for the test partition using :meth:`predict`. +# We can predict values for the test partition using +# :meth:`~skfda.ml.regression.KNeighborsScalarRegressor.predict`. # pred = knn.predict(X_test) print(pred) -################################################################################ +############################################################################## # # The following figure compares the real precipitations with the predicted # values. # -plt.figure() -plt.scatter(y_test, pred) -plt.plot(y_test, y_test) -plt.xlabel("Total log precipitation") -plt.ylabel("Prediction") +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) +ax.scatter(y_test, pred) +ax.plot(y_test, y_test) +ax.set_xlabel("Total log precipitation") +ax.set_ylabel("Prediction") -################################################################################ +############################################################################## # # We can quantify how much variability it is explained by the model with # the coefficient of determination :math:`R^2` of the prediction, -# using :meth:`score` for that. +# using :meth:`~skfda.ml.regression.KNeighborsScalarRegressor.score` for that. # # The coefficient :math:`R^2` is defined as :math:`(1 - u/v)`, where :math:`u` # is the residual sum of squares :math:`\sum_i (y_i - y_{pred_i})^ 2` @@ -126,7 +131,7 @@ print(score) -################################################################################ +############################################################################## # # In this case, we obtain a really good aproximation with this naive approach, # although, due to the small number of samples, the results will depend on @@ -136,12 +141,12 @@ # We will perform cross-validation to test more robustly our model. # # As in the neighbors classifiers examples, we can use a sklearn metric to -# approximate the :math:`\mathbb{L}^2` metric between function, but with a much -# lower computational cost. +# approximate the :math:`\mathbb{L}^2` metric between function, but with a +# much lower computational cost. # # Also, we can make a grid search, using -# :class:`sklearn.model_selection.GridSearchCV`, to determine the optimal number -# of neighbors and the best way to weight their votes. +# :class:`~sklearn.model_selection.GridSearchCV`, to determine the optimal +# number of neighbors and the best way to weight their votes. # param_grid = {'n_neighbors': np.arange(1, 12, 2), @@ -153,7 +158,7 @@ shuffle=True, random_state=0)) gscv.fit(X, log_prec) -################################################################################ +############################################################################## # # We obtain that 7 is the optimal number of neighbors. # @@ -161,9 +166,9 @@ print("Best params", gscv.best_params_) print("Best score", gscv.best_score_) -################################################################################ +############################################################################## # -# More detailed information about the canadian weather dataset can be obtained +# More detailed information about the Canadian weather dataset can be obtained # in the following references. # # * Ramsay, James O., and Silverman, Bernard W. (2006). Functional Data @@ -172,5 +177,3 @@ # * Ramsay, James O., and Silverman, Bernard W. (2002). Applied Functional # Data Analysis, Springer, New York\n' # - -plt.show() diff --git a/examples/plot_pairwise_alignment.py b/examples/plot_pairwise_alignment.py index 1d3cabdca..b547f2b25 100644 --- a/examples/plot_pairwise_alignment.py +++ b/examples/plot_pairwise_alignment.py @@ -11,13 +11,13 @@ # sphinx_gallery_thumbnail_number = 5 -import skfda -import matplotlib.pyplot as plt import matplotlib.colors as clr +import matplotlib.pyplot as plt import numpy as np +import skfda -############################################################################### +############################################################################## # Given any two functions :math:`f` and :math:`g`, we define their # pairwise alignment or registration to be the problem of finding a warping # function :math:`\gamma^*` such that a certain energy term @@ -35,22 +35,24 @@ # # Firstly, we will create two unimodal samples, :math:`f` and :math:`g`, # defined in [0, 1] wich will be used to show the elastic registration. -# Due to the similarity of these curves can be aligned almost perfectly between -# them. +# Due to the similarity of these curves can be aligned almost perfectly +# between them. # +# Samples with modes in 1/3 and 2/3 +fd = skfda.datasets.make_multimodal_samples( + n_samples=2, modes_location=[1 / 3, 2 / 3], + random_state=1, start=0, mode_std=.01) -# Samples with modes in 1/3 and 2/3 -fd = skfda.datasets.make_multimodal_samples(n_samples=2, modes_location=[1/3,2/3], - random_state=1, start=0, mode_std=.01) +fig = fd.plot() +fig.axes[0].legend(['$f$', '$g$']) -fd.plot() -plt.legend(['$f$', '$g$']) +fig -############################################################################### +############################################################################## # In this example :math:`g` will be used as template and :math:`f` will be # aligned to it. In the following figure it is shown the result of the -# registration process, wich can be computed using :func:`elastic_registration -# `. +# registration process, wich can be computed using +# :func:`~skfda.preprocessing.registration.elastic_registration`. # f, g = fd[0], fd[1] @@ -58,38 +60,37 @@ # Aligns f to g fd_align = skfda.preprocessing.registration.elastic_registration(f, g) - -plt.figure() - -fd.plot() -fd_align.plot(color='C0', linestyle='--') +fig = fd.plot() +fd_align.plot(fig=fig, color='C0', linestyle='--') # Legend -plt.legend(['$f$', '$g$', '$f \\circ \\gamma $']) +fig.axes[0].legend(['$f$', '$g$', '$f \\circ \\gamma $']) + +fig -############################################################################### +############################################################################## # The non-linear transformation :math:`\gamma` applied to :math:`f` in -# the alignment can be obtained using :func:`elastic_registration_warping -# `. +# the alignment can be obtained using +# :func:`~skfda.preprocessing.registration.elastic_registration_warping`. # # Warping to align f to g warping = skfda.preprocessing.registration.elastic_registration_warping(f, g) -plt.figure() - # Warping used -warping.plot() +fig = warping.plot() -# Plot identity +# Plot identity t = np.linspace(0, 1) -plt.plot(t, t, linestyle='--') +fig.axes[0].plot(t, t, linestyle='--') # Legend -plt.legend(['$\\gamma$', '$\\gamma_{id}$']) +fig.axes[0].legend(['$\\gamma$', '$\\gamma_{id}$']) + +fig -############################################################################### +############################################################################## # The transformation necessary to align :math:`g` to :math:`f` will be the # inverse of the original warping function, :math:`\gamma^{-1}`. # This fact is a consequence of the use of the Fisher-Rao metric as energy @@ -98,20 +99,19 @@ warping_inverse = skfda.preprocessing.registration.invert_warping(warping) - -plt.figure() - -fd.plot(label='$f$') -g.compose(warping_inverse).plot(color='C1', linestyle='--') +fig = fd.plot(label='$f$') +g.compose(warping_inverse).plot(fig=fig, color='C1', linestyle='--') # Legend -plt.legend(['$f$', '$g$', '$g \\circ \\gamma^{-1} $']) +fig.axes[0].legend(['$f$', '$g$', '$g \\circ \\gamma^{-1} $']) +fig -############################################################################### -# The amount of deformation used in the registration can be controlled by using -# a variation of the metric with a penalty term + +############################################################################## +# The amount of deformation used in the registration can be controlled by +# using a variation of the metric with a penalty term # :math:`\lambda \mathcal{R}(\gamma)` wich will reduce the elasticity of the # metric. # @@ -123,39 +123,47 @@ lambdas = np.linspace(0, .2, 20) # Creation of a color gradient -cmap = clr.LinearSegmentedColormap.from_list('custom cmap', ['C1','C0']) -color = cmap(.2 + 3*lambdas) +cmap = clr.LinearSegmentedColormap.from_list('custom cmap', ['C1', 'C0']) +color = cmap(.2 + 3 * lambdas) -plt.figure() +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) for lam, c in zip(lambdas, color): # Plots result of alignment - skfda.preprocessing.registration.elastic_registration(f, g, lam=lam).plot(color=c) + skfda.preprocessing.registration.elastic_registration( + f, g, lam=lam).plot(fig=fig, color=c) -f.plot(color='C0', linewidth=2., label='$f$') -g.plot(color='C1', linewidth=2., label='$g$') +f.plot(fig=fig, color='C0', linewidth=2., label='$f$') +g.plot(fig=fig, color='C1', linewidth=2., label='$g$') # Legend -plt.legend() +fig.axes[0].legend() + +fig -############################################################################### +############################################################################## # This phenomenon of loss of elasticity is clearly observed in # the warpings used, since as the term of penalty increases, the functions # are closer to :math:`\gamma_{id}`. # -plt.figure() +fig = plt.figure() +ax = fig.add_subplot(1, 1, 1) for lam, c in zip(lambdas, color): - skfda.preprocessing.registration.elastic_registration_warping(f, g, lam=lam).plot(color=c) + skfda.preprocessing.registration.elastic_registration_warping( + f, g, lam=lam).plot(fig=fig, color=c) # Plots identity -plt.plot(t,t, color='C0', linestyle="--") +fig.axes[0].plot(t, t, color='C0', linestyle="--") +fig -############################################################################### + +############################################################################## # We can perform the pairwise of multiple curves at once. We can use a single # curve as template to align a set of samples to it or a set of # templates to make the alignemnt the two sets. @@ -163,58 +171,55 @@ # In the elastic registration example it is shown the alignment of multiple # curves to the same template. # -# We will build two sets with 3 curves each, :math:`\{f_i\}` and :math:`\{g_i\}`. +# We will build two sets with 3 curves each, :math:`\{f_i\}` and +# :math:`\{g_i\}`. # # Creation of the 2 sets of functions state = np.random.RandomState(0) location1 = state.normal(loc=-.3, scale=.1, size=3) -fd = skfda.datasets.make_multimodal_samples(n_samples=3, modes_location=location1, - noise=.001 ,random_state=1) +fd = skfda.datasets.make_multimodal_samples( + n_samples=3, modes_location=location1, noise=.001, random_state=1) location2 = state.normal(loc=.3, scale=.1, size=3) -g = skfda.datasets.make_multimodal_samples(n_samples=3, modes_location=location2, - random_state=2) +g = skfda.datasets.make_multimodal_samples( + n_samples=3, modes_location=location2, random_state=2) # Plot of the sets -plt.figure() +fig = fd.plot(color="C0", label="$f_i$") +g.plot(fig=fig, color="C1", label="$g_i$") -fd.plot(color="C0", label="$f_i$") -fig, ax = g.plot(color="C1", label="$g_i$") +labels = fig.axes[0].get_lines() +fig.axes[0].legend(handles=[labels[0], labels[-1]]) -l = ax[0].get_lines() -plt.legend(handles=[l[0], l[-1]]) +fig -############################################################################### +############################################################################## # The following figure shows the result of the pairwise alignment of # :math:`\{f_i\}` to :math:`\{g_i\}`. # - -plt.figure() - # Registration of the sets fd_registered = skfda.preprocessing.registration.elastic_registration(fd, g) # Plot of the curves -fig, ax = fd.plot(color="C0", label="$f_i$") -l1 = ax[0].get_lines()[-1] -g.plot(color="C1", label="$g_i$") -l2 = ax[0].get_lines()[-1] -fd_registered.plot(color="C0", linestyle="--", label="$f_i \\circ \\gamma_i$") -l3 = ax[0].get_lines()[-1] - -plt.legend(handles=[l1, l2, l3]) +fig = fd.plot(color="C0", label="$f_i$") +l1 = fig.axes[0].get_lines()[-1] +g.plot(fig=fig, color="C1", label="$g_i$") +l2 = fig.axes[0].get_lines()[-1] +fd_registered.plot(fig=fig, color="C0", linestyle="--", + label="$f_i \\circ \\gamma_i$") +l3 = fig.axes[0].get_lines()[-1] -plt.show() +fig.axes[0].legend(handles=[l1, l2, l3]) -############################################################################### +############################################################################## # * Srivastava, Anuj & Klassen, Eric P. (2016). Functional and shape data # analysis. In *Functional Data and Elastic Registration* (pp. 73-122). # Springer. # -# * J. S. Marron, James O. Ramsay, Laura M. Sangalli and Anuj Srivastava (2015). -# Functional Data Analysis of Amplitude and Phase Variation. +# * J. S. Marron, James O. Ramsay, Laura M. Sangalli and Anuj Srivastava +# (2015). Functional Data Analysis of Amplitude and Phase Variation. # Statistical Science 2015, Vol. 30, No. 4 diff --git a/examples/plot_radius_neighbors_classification.py b/examples/plot_radius_neighbors_classification.py index a4deadcb1..91cdc20de 100644 --- a/examples/plot_radius_neighbors_classification.py +++ b/examples/plot_radius_neighbors_classification.py @@ -11,25 +11,25 @@ # sphinx_gallery_thumbnail_number = 2 -import skfda +from sklearn.model_selection import train_test_split + import matplotlib.pyplot as plt import numpy as np -from sklearn.model_selection import train_test_split -from skfda.ml.classification import RadiusNeighborsClassifier +import skfda from skfda.misc.metrics import pairwise_distance, lp_distance +from skfda.ml.classification import RadiusNeighborsClassifier -################################################################################ +############################################################################## # # In this example, we are going to show the usage of the radius nearest -# neighbors classifier in their functional version, a variation of the K-nearest -# neighbors classifier, where it is used a vote among neighbors within a given -# radius, instead of use the k nearest neighbors. +# neighbors classifier in their functional version, a variation of the +# K-nearest neighbors classifier, where it is used a vote among neighbors +# within a given radius, instead of use the k nearest neighbors. # # Firstly, we will construct a toy dataset to show the basic usage of the API. # We will create two classes of sinusoidal samples, with different phases. # - # Make toy dataset fd1 = skfda.datasets.make_sinusoidal_process(error_std=.0, phase_std=.35, random_state=0) @@ -37,29 +37,24 @@ random_state=1) X = fd1.concatenate(fd2) -y = np.array(15*[0] + 15*[1]) +y = np.array(15 * [0] + 15 * [1]) # Plot toy dataset -plt.figure() X.plot(sample_labels=y, label_colors=['C0', 'C1']) - - -################################################################################ +############################################################################## # # As in the K-nearest neighbor example, we will split the dataset in two # partitions, for training and test, using the sklearn function -# :func:`sklearn.model_selection.train_test_split`. - -# Concatenate the two classes in the same FDataGrid +# :func:`~sklearn.model_selection.train_test_split`. +# Concatenate the two classes in the same FDataGrid. X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.25, shuffle=True, stratify=y, random_state=0) - -################################################################################ +############################################################################## # # The label assigned to a test sample will be the # majority class of its neighbors, in this case all the samples in the ball @@ -72,20 +67,20 @@ # radius = 0.3 -sample = X_test[0] # Center of the ball +sample = X_test[0] # Center of the ball -plt.figure() -X_train.plot(sample_labels=y_train, label_colors=['C0', 'C1']) +fig = X_train.plot(sample_labels=y_train, label_colors=['C0', 'C1']) # Plot ball -sample.plot(color='red', linewidth=3) +sample.plot(fig=fig, color='red', linewidth=3) lower = sample - radius upper = sample + radius -plt.fill_between(sample.sample_points[0], lower.data_matrix.flatten(), - upper.data_matrix[0].flatten(), alpha=.25, color='C1') +fig.axes[0].fill_between( + sample.sample_points[0], lower.data_matrix.flatten(), + upper.data_matrix[0].flatten(), alpha=.25, color='C1') -################################################################################ +############################################################################## # # In this case, all the neighbors in the ball belong to the first class, so # this will be the class predicted. @@ -94,22 +89,25 @@ # Creation of pairwise distance l_inf = pairwise_distance(lp_distance, p=np.inf) -distances = l_inf(sample, X_train)[0] # L_inf distances to 'sample' +distances = l_inf(sample, X_train)[0] # L_inf distances to 'sample' # Plot samples in the ball -plt.figure() -X_train[distances <= radius].plot(color='C0') -sample.plot(color='red', linewidth=3) -plt.fill_between(sample.sample_points[0], lower.data_matrix.flatten(), - upper.data_matrix[0].flatten(), alpha=.25, color='C1') +fig = X_train[distances <= radius].plot(color='C0') +sample.plot(fig=fig, color='red', linewidth=3) +fig.axes[0].fill_between( + sample.sample_points[0], lower.data_matrix.flatten(), + upper.data_matrix[0].flatten(), alpha=.25, color='C1') -################################################################################ +############################################################################## # -# We will fit the classifier :class:`RadiusNeighborsClassifier -# `, which has a similar API -# than the sklearn estimator :class:`sklearn.neighbors.RadiusNeighborsClassifier` -# but accepting :class:`FDataGrid` instead of arrays with multivariate data. +# We will fit the classifier +# :class:`~skfda.ml.classification.RadiusNeighborsClassifier`, which has a +# similar API +# than the sklearn estimator +# :class:`~sklearn.neighbors.RadiusNeighborsClassifier` +# but accepting :class:`~skfda.representation.grid.FDataGrid` instead of +# arrays with multivariate data. # # The vote of the neighbors can be weighted using the paramenter ``weights``. # In this case we will weight the vote inversely proportional to the distance. @@ -119,33 +117,35 @@ radius_nn.fit(X_train, y_train) -################################################################################ +############################################################################## # -# We can predict labels for the test partition with :meth:`predict`. +# We can predict labels for the test partition with +# :meth:`~skfda.ml.classification.RadiusNeighborsClassifier.predict`. # pred = radius_nn.predict(X_test) print(pred) -################################################################################ +############################################################################## # -# In this case, we get 100% accuracy, althouth, it is a toy dataset. +# In this case, we get 100% accuracy, although it is a toy dataset. # test_score = radius_nn.score(X_test, y_test) print(test_score) -################################################################################ +############################################################################## # -# As in the K-nearest neighbor example, we can use the euclidean sklearn metric -# approximately equivalent to the functional :math:`\mathbb{L}^2` one, +# As in the K-nearest neighbor example, we can use the euclidean sklearn +# metric approximately equivalent to the functional :math:`\mathbb{L}^2` one, # but computationally faster. # -# We saw that :math:`\|f -g \|_{\mathbb{L}^2} \approx \sqrt{\bigtriangleup h} \, -# d_{euclidean}(\vec{f}, \vec{g})` if the samples are equiespaced (or almost). +# We saw that :math:`\|f -g \|_{\mathbb{L}^2} \approx \sqrt{\bigtriangleup h} +# \, d_{euclidean}(\vec{f}, \vec{g})` if the samples are equiespaced (or +# almost). # -# In the KNN case, the constant :math:`\sqrt{\bigtriangleup h}` does not matter, -# but in this case will affect the value of the radius, dividing by +# In the KNN case, the constant :math:`\sqrt{\bigtriangleup h}` does not +# matter, but in this case will affect the value of the radius, dividing by # :math:`\sqrt{\bigtriangleup h}`. # # In this dataset :math:`\bigtriangleup h=0.001`, so, we have to multiply the @@ -167,13 +167,13 @@ print(test_score) -################################################################################ +############################################################################## # # If the radius is too small, it is possible to get samples with no neighbors. # The classifier will raise and exception in this case. # -radius_nn.set_params(radius=.5) # Radius 0.05 in the L2 distance +radius_nn.set_params(radius=.5) #  Radius 0.05 in the L2 distance radius_nn.fit(X_train, y_train) try: @@ -181,7 +181,7 @@ except ValueError as e: print(e) -################################################################################ +############################################################################## # # A label to these oulier samples can be provided to avoid this problem. # @@ -192,10 +192,8 @@ print(pred) -################################################################################ +############################################################################## # # This classifier can be used with multivariate funcional data, as surfaces # or curves in :math:`\mathbb{R}^N`, if the metric support it too. # - -plt.show() diff --git a/examples/plot_representation.py b/examples/plot_representation.py index 06a2fab70..2f99691a6 100644 --- a/examples/plot_representation.py +++ b/examples/plot_representation.py @@ -13,14 +13,15 @@ from skfda.representation.interpolation import SplineInterpolator -############################################################################### +############################################################################## # In this example we are going to show the different representations of # functional data available in scikit-fda. # # First we are going to fetch a functional data dataset, such as the Berkeley -# Growth Study. This dataset correspond to the height of several boys and girls -# measured until the 18 years of age. The number and times of the measurements -# are the same for each individual. +# Growth Study. This dataset correspond to the height of several boys and +# girls measured until the 18 years of age. The number and times of the +# measurements are the same for each individual. + dataset = skfda.datasets.fetch_growth() fd = dataset['data'] y = dataset['target'] @@ -29,16 +30,16 @@ fd.plot(sample_labels=y, label_colors=['red', 'blue']) -############################################################################### +############################################################################## # This kind of representation is a discretized representation, in which the # measurement points are shared between samples. print(fd.sample_points) -############################################################################### +############################################################################## # In this representation, the data can be arranged as a matrix. print(fd.data_matrix) -############################################################################### +############################################################################## # By default, the data points are interpolated using a linear interpolation, # but this is configurable. dataset = skfda.datasets.fetch_medflies() @@ -47,13 +48,13 @@ first_curve = fd[0] first_curve.plot() -############################################################################### +############################################################################## # The interpolation used can however be changed. Here, we will use an # interpolation with degree 3 splines. first_curve.interpolator = SplineInterpolator(3) first_curve.plot() -############################################################################### +############################################################################## # This representation allows also functions with arbitrary dimensions of the # domain and codomain. fd = skfda.datasets.make_multimodal_samples(n_samples=1, dim_domain=2, @@ -64,7 +65,7 @@ fd.plot() -############################################################################### +############################################################################## # Another possible representation is a decomposition in a basis of functions. # $$ # f(t) = \\sum_{i=1}^N a_i \\phi_i(t) @@ -77,40 +78,34 @@ fd.plot() -############################################################################### +############################################################################## # We will represent it using a basis of B-splines. -fd_basis = fd.to_basis( - basis.BSpline(nbasis=4) -) +fd_basis = fd.to_basis(basis.BSpline(nbasis=4)) fd_basis.plot() -############################################################################### +############################################################################## # We can increase the number of elements in the basis to try to reproduce the # original data with more fidelity. -fd_basis_big = fd.to_basis( - basis.BSpline(nbasis=7) -) +fd_basis_big = fd.to_basis(basis.BSpline(nbasis=7)) fd_basis_big.plot() -############################################################################## +############################################################################# # Lets compare the diferent representations in the same plot, for the same # curve -fig, ax = fd[0].plot() -fd_basis[0].plot(fig) -fd_basis_big[0].plot(fig) +fig = fd[0].plot() +fd_basis[0].plot(fig=fig) +fd_basis_big[0].plot(fig=fig) -ax[0].legend(['Original', '4 elements', '7 elements']) +fig.axes[0].legend(['Original', '4 elements', '7 elements']) ############################################################################## # We can also see the effect of changing the basis. # For example, in the Fourier basis the functions start and end at the same # points if the period is equal to the domain range, so this basis is clearly # non suitable for the Growth dataset. -fd_basis = fd.to_basis( - basis.Fourier(nbasis=7) -) +fd_basis = fd.to_basis(basis.Fourier(nbasis=7)) fd_basis.plot() diff --git a/examples/plot_shift_registration_basis.py b/examples/plot_shift_registration_basis.py index 316c6c100..6b7a39505 100644 --- a/examples/plot_shift_registration_basis.py +++ b/examples/plot_shift_registration_basis.py @@ -11,24 +11,24 @@ # sphinx_gallery_thumbnail_number = 3 -import skfda import matplotlib.pyplot as plt +import skfda -############################################################################### +############################################################################## # In this example we will use a # :func:`sinusoidal process ` -# synthetically generated. This dataset consists in a sinusoidal wave with fixed -# period which contanis phase and amplitude variation with gaussian noise. +# synthetically generated. This dataset consists in a sinusoidal wave with +# fixed period which contanis phase and amplitude variation with gaussian +# noise. # # In this example we want to register the curves using a translation # and remove the phase variation to perform further analysis. - fd = skfda.datasets.make_sinusoidal_process(random_state=1) fd.plot() -############################################################################### +############################################################################## # We will smooth the curves using a basis representation, which will help us # to remove the gaussian noise. Smoothing before registration # is essential due to the use of derivatives in the optimization process. @@ -38,54 +38,51 @@ basis = skfda.representation.basis.Fourier(nbasis=11) fd_basis = fd.to_basis(basis) -plt.figure() fd_basis.plot() -############################################################################### +############################################################################## # We will apply the -# :func:`shift registration `, +# :func:`~skfda.preprocessing.registration.shift_registration`, # which is suitable due to the periodicity of the dataset and the small # amount of amplitude variation. fd_registered = skfda.preprocessing.registration.shift_registration(fd_basis) -############################################################################### +############################################################################## # We can observe how the sinusoidal pattern is easily distinguishable # once the alignment has been made. -plt.figure() fd_registered.plot() -############################################################################### -# We will plot the mean of the original smoothed curves and the registered ones, -# and we will compare with the original sinusoidal process without noise. +############################################################################## +# We will plot the mean of the original smoothed curves and the registered +# ones, and we will compare with the original sinusoidal process without +# noise. # -# We can see how the phase variation affects to the mean of the original curves -# varying their amplitude with respect to the original process, however, this -# effect is mitigated after the registration. - -plt.figure() +# We can see how the phase variation affects to the mean of the original +# curves varying their amplitude with respect to the original process, +# however, this effect is mitigated after the registration. -fd_basis.mean().plot() -fd_registered.mean().plot() +fig = fd_basis.mean().plot() +fd_registered.mean().plot(fig=fig) # sinusoidal process without variation and noise sine = skfda.datasets.make_sinusoidal_process(n_samples=1, phase_std=0, - amplitude_std=0, error_std=0) + amplitude_std=0, error_std=0) -sine.plot(linestyle='dashed') +sine.plot(fig=fig, linestyle='dashed') -plt.legend(['original mean', 'registered mean','sine']) +fig.axes[0].legend(['original mean', 'registered mean', 'sine']) -############################################################################### +############################################################################## # The values of the shifts :math:`\delta_i` may be relevant for further -# analysis, as they may be considered as nuisance or random effects. +# analysis, as they may be considered as nuisance or random effects. # deltas = skfda.preprocessing.registration.shift_registration_deltas(fd_basis) print(deltas) -############################################################################### +############################################################################## # The aligned functions can be obtained from the :math:`\delta_i` list # using the `shift` method. # diff --git a/examples/plot_surface_boxplot.py b/examples/plot_surface_boxplot.py index afab2bd67..c15bc7223 100644 --- a/examples/plot_surface_boxplot.py +++ b/examples/plot_surface_boxplot.py @@ -14,20 +14,21 @@ import matplotlib.pyplot as plt import numpy as np from skfda import FDataGrid -from skfda.datasets import make_sinusoidal_process, make_gaussian_process +from skfda.datasets import make_gaussian_process from skfda.exploratory.visualization import SurfaceBoxplot, Boxplot ############################################################################## -# In order to instantiate a :func:`surface boxplot object -# `, a functional data object with bidimensional -# domain must be generated. In this example, a FDataGrid representing a -# function :math:`f : \mathbb{R}^2\longmapsto\mathbb{R}` is constructed, +# In order to instantiate a +# :class:`~skfda.exploratory.visualization.SurfaceBoxplot`, a functional data +# object with bidimensional domain must be generated. In this example, a +# FDataGrid representing a function +# :math:`f : \mathbb{R}^2\longmapsto\mathbb{R}` is constructed, # using as an example a Brownian process extruded into another dimension. # # The values of the Brownian process are generated using -# :func:`make_gaussian_process method `, -# Those functions return FDataGrid objects whose 'data_matrix' +# :func:`~skfda.datasets.make_gaussian_process`, +# Those functions return FDataGrid objects whose ``data_matrix`` # store the values needed. n_samples = 10 n_features = 10 @@ -51,7 +52,6 @@ sample_points=np.tile(fd.sample_points, (2, 1)), dataset_label="Extruded Brownian process") -plt.figure() fd_2.plot() ############################################################################## @@ -65,15 +65,14 @@ # first two generated functional data objects, are plotted below, to help to # visualize the data. -plt.figure() fd.plot() ############################################################################## -# To terminate the example, the instantiation of the SurfaceBoxplot object is +# To terminate the example, the instantiation of the +# :class:`~skfda.exploratory.visualization.SurfaceBoxplot` object is # made, showing the surface boxplot which corresponds to our FDataGrid surfaceBoxplot = SurfaceBoxplot(fd_2) -plt.figure() surfaceBoxplot.plot() ############################################################################## @@ -83,10 +82,9 @@ # # Analogous to the procedure followed before of plotting the three-dimensional # data and their correponding profiles, we can obtain also the functional -# boxplot for one-dimensional data with the :func:`fdboxplot function -# ` passing as arguments the first FdataGrid -# object. The profile of the surface boxplot is obtained. +# boxplot for one-dimensional data with the +# :class:`~skfda.exploratory.visualization.Boxplot` passing as arguments the +# first FdataGrid object. The profile of the surface boxplot is obtained. -plt.figure() boxplot1 = Boxplot(fd) boxplot1.plot() diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index a08839c6f..8201ff20a 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -795,14 +795,14 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, """ if self.dim_domain > 2: raise NotImplementedError("Plot only supported for functional data" - "modeled in at most 3 dimensions.") + " modeled in at most 3 dimensions.") if fig is not None and ax is not None: raise ValueError("fig and axes parameters cannot be passed as " "arguments at the same time.") if fig is not None and len(fig.get_axes()) != self.dim_codomain: - raise ValueError("Number of axes of the figure must be equal to" + raise ValueError("Number of axes of the figure must be equal to " "the dimension of the image.") if ax is not None and len(ax) != self.dim_codomain: @@ -825,7 +825,7 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, fig, ax = self.set_figure_and_axes(nrows, ncols) elif fig is not None: - ax = fig.get_axes() + ax = fig.axes else: fig = ax[0].get_figure() From 39e2dc1e82abb6e537a7012068e0e489da22ce58 Mon Sep 17 00:00:00 2001 From: Pablo Marcos Date: Thu, 5 Sep 2019 19:53:38 +0200 Subject: [PATCH 199/222] Issue templates Templates to facilitate the creation of issues to people outside the project. --- .github/ISSUE_TEMPLATE/bug_report.md | 33 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..2f3c3c6c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,33 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Code to reproduce the behavior: + +```python +import skfda +... + +``` + +**Expected behavior** +A clear and concise description of what you expected to happen, results or figures. + + +**Version information** + - OS: [e.g. Linux, MacOs, Windows] + - Python version: [e.g. 3.6, 3.7] + - scikit-fda version: [e.g. develop, 0.23] + - Version of other packages involved [e.g. numpy, scipy, matplotlib, ... ] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..11fc491ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From 40016a2b98b47f14b5fdd073b1242068aeb17a1d Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 6 Sep 2019 16:22:02 +0200 Subject: [PATCH 200/222] Change `_create_figure` to always use `plt.figure()` for now. --- docs/conf.py | 1 - skfda/_utils/_utils.py | 17 +---------------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index afe208372..18a8d6170 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -238,4 +238,3 @@ } autosummary_generate = True -os.environ['_SKFDA_USE_PYPLOT'] = '1' diff --git a/skfda/_utils/_utils.py b/skfda/_utils/_utils.py index ba0a1d061..546611644 100644 --- a/skfda/_utils/_utils.py +++ b/skfda/_utils/_utils.py @@ -2,7 +2,6 @@ import functools import io -import os import types import matplotlib.backends.backend_svg @@ -146,21 +145,7 @@ def _check_estimator(estimator): def _create_figure(): """Create figure using the default backend.""" - - if '_SKFDA_USE_PYPLOT' in os.environ: - use_pyplot = os.environ['_SKFDA_USE_PYPLOT'] == '1' - else: - use_pyplot = False - - if use_pyplot: - fig = plt.figure() - else: - fig = matplotlib.figure.Figure() - - # Get the default backend - backend = plt.new_figure_manager.__self__ - - backend.new_figure_manager_given_figure(1, fig) + fig = plt.figure() return fig From d63cda9ddb078617f23892d8aec52f235c86a592 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 7 Sep 2019 00:21:28 +0200 Subject: [PATCH 201/222] Unify neighbors regressors --- skfda/_neighbors/__init__.py | 14 +- skfda/_neighbors/base.py | 191 +++++++++++---- skfda/_neighbors/classification.py | 15 +- skfda/_neighbors/regression.py | 381 ++++++++--------------------- skfda/_neighbors/unsupervised.py | 28 ++- skfda/ml/regression/__init__.py | 6 +- tests/test_neighbors.py | 45 ++-- 7 files changed, 303 insertions(+), 377 deletions(-) diff --git a/skfda/_neighbors/__init__.py b/skfda/_neighbors/__init__.py index 9134bfb08..58316566d 100644 --- a/skfda/_neighbors/__init__.py +++ b/skfda/_neighbors/__init__.py @@ -4,17 +4,11 @@ - KNeighborsClassifier - RadiusNeighborsClassifier - NearestCentroids - - KNeighborsScalarRegressor - - RadiusNeighborsScalarRegressor - - KNeighborsFunctionalRegressor - - RadiusNeighborsFunctionalRegressor -""" + - KNeighborsRegressor + - RadiusNeighborsRegressor +""" from .unsupervised import NearestNeighbors - +from .regression import KNeighborsRegressor, RadiusNeighborsRegressor from .classification import (KNeighborsClassifier, RadiusNeighborsClassifier, NearestCentroids) -from .regression import (KNeighborsFunctionalRegressor, - KNeighborsScalarRegressor, - RadiusNeighborsFunctionalRegressor, - RadiusNeighborsScalarRegressor) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 2bf971d6c..ac16fbbd5 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -2,14 +2,13 @@ from abc import ABC, abstractmethod -import scipy.integrate from sklearn.base import BaseEstimator -from sklearn.neighbors import NearestNeighbors as _NearestNeighbors from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted +from sklearn.base import RegressorMixin import numpy as np -from .. import FDataGrid +from .. import FDataGrid, FData from ..exploratory.stats import mean as l2_mean from ..misc.metrics import lp_distance @@ -436,57 +435,32 @@ def predict(self, X): return self.estimator_.predict(X) -class NeighborsScalarRegresorMixin: - """Mixin class for scalar regressor based in nearest neighbors""" +class NeighborsRegressorMixin(NeighborsMixin, RegressorMixin): + """Mixin class for the regressors based on neighbors""" - def predict(self, X): - """Predict the target for the provided data - Parameters - ---------- - X (:class:`FDataGrid` or array-like): FDataGrid with the test - samples or array (n_query, n_indexed) if metric == - 'precomputed'. - Returns - ------- - y : array of int, shape = [n_samples] or [n_samples, n_outputs] - Target values - Notes - ----- - This method wraps the corresponding sklearn routine in the module - ``sklearn.neighbors``. - - """ - self._check_is_fitted() - - X = self._transform_to_multivariate(X) - - return self.estimator_.predict(X) - - -class NearestNeighborsMixinInit: - def _init_estimator(self, sk_metric): - """Initialize the sklearn nearest neighbors estimator. + def fit(self, X, y): + """Fit the model using X as training data and y as responses. Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - + X (:class:`FDataGrid`, array_matrix): Training data. FDataGrid + with the training data or array matrix with shape + [n_samples, n_samples] if metric='precomputed'. + Y (:class:`FData` or array_like): Training data. FData + with the training respones (functional response case) + or array matrix with length `n_samples` in the multivariate + response case. Returns: - Sklearn K Neighbors estimator initialized. + Estimator: self. """ - return _NearestNeighbors( - n_neighbors=self.n_neighbors, radius=self.radius, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) + self._functional = isinstance(y, FData) + if self._functional: + return self._functional_fit(X, y) + else: + return super().fit(X, y) -class NeighborsFunctionalRegressorMixin: - """Mixin class for the functional regressors based in neighbors""" - - def fit(self, X, y): + def _functional_fit(self, X, y): """Fit the model using X as training data. Args: @@ -565,6 +539,73 @@ def _weighted_local_regression(self, neighbors, distance): return self._regressor(neighbors, weights) def predict(self, X): + """Predict the target for the provided data + Parameters + ---------- + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + Returns + ------- + y : array of shape = [n_samples] or [n_samples, n_outputs] + or :class:`FData` containing as many samples as X. + + """ + + self._check_is_fitted() + + # Choose type of prediction + if self._functional: + return self._functional_predict(X) + else: + return self._multivariate_predict(X) + + def _multivariate_predict(self, X): + """Predict the target for the provided data + Parameters + ---------- + X (:class:`FDataGrid` or array-like): FDataGrid with the test + samples or array (n_query, n_indexed) if metric == + 'precomputed'. + Returns + ------- + y : array of int, shape = [n_samples] or [n_samples, n_outputs] + Target values + Notes + ----- + This method wraps the corresponding sklearn routine in the module + ``sklearn.neighbors``. + + """ + + X = self._transform_to_multivariate(X) + + return self.estimator_.predict(X) + + def _init_estimator(self, sk_metric): + """Initialize the sklearn nearest neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + if self._functional: + from sklearn.neighbors import NearestNeighbors as _NearestNeighbors + + return _NearestNeighbors( + n_neighbors=self.n_neighbors, radius=self.radius, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) + else: + return self._init_multivariate_estimator(sk_metric) + + def _functional_predict(self, X): """Predict functional responses. Args: @@ -577,7 +618,6 @@ def predict(self, X): y : :class:`FDataGrid` containing as many samples as X. """ - self._check_is_fitted() X = self._transform_to_multivariate(X) @@ -612,7 +652,55 @@ def _outlier_response(self, neighbors): else: return self.outlier_response + def score(self, X, y, sample_weight=None): + + r"""Return the coefficient of determination R^2 of the prediction. + + In the multivariate response case, the coefficient :math:`R^2` is + defined as + + .. math:: + 1 - \frac{\sum_{i=1}^{n} (y_i - \hat y_i)^2} + {\sum_{i=1}^{n} (y_i - \frac{1}{n}\sum_{i=1}^{n}y_i)^2} + + where :math:`\hat{y}_i` is the prediction associated to the test sample + :math:`X_i`, and :math:`{y}_i` is the true response. See + :func:`sklearn.metrics.r2_score ` for more + information. + + + In the functional case it is returned an extension of the coefficient + of determination :math:`R^2`, defined as + + .. math:: + 1 - \frac{\sum_{i=1}^{n}\int (y_i(t) - \hat{y}_i(t))^2dt} + {\sum_{i=1}^{n} \int (y_i(t)- \frac{1}{n}\sum_{i=1}^{n}y_i(t))^2dt} + + + The best possible score is 1.0 and it can be negative + (because the model can be arbitrarily worse). A constant model that + always predicts the expected value of y, disregarding the input + features, would get a R^2 score of 0.0. + + Args: + X (FDataGrid): Test samples to be predicted. + y (FData or array-like): True responses of the test samples. + sample_weight (array_like, shape = [n_samples], optional): Sample + weights. + + Returns: + (float): Coefficient of determination. + + """ + if self._functional: + return self._functional_score(X, y, sample_weight=sample_weight) + else: + # Default sklearn multivariate score + return super().score(X, y, sample_weight=sample_weight) + + + def _functional_score(self, X, y, sample_weight=None): r"""Return an extension of the coefficient of determination R^2. The coefficient is defined as @@ -639,6 +727,11 @@ def score(self, X, y, sample_weight=None): (float): Coefficient of determination. """ + + # TODO: If it is created a module in ml.regression with other + # score metrics, move it. + from scipy.integrate import simps + if y.dim_codomain != 1 or y.dim_domain != 1: raise ValueError("Score not implemented for multivariate " "functional data.") @@ -676,7 +769,7 @@ def score(self, X, y, sample_weight=None): sum_u = np.sum(data_u, axis=0) sum_v = np.sum(data_v, axis=0) - int_u = scipy.integrate.simps(sum_u, x=u.sample_points[0]) - int_v = scipy.integrate.simps(sum_v, x=v.sample_points[0]) + int_u = simps(sum_u, x=u.sample_points[0]) + int_v = simps(sum_v, x=v.sample_points[0]) return 1 - int_u / int_v diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 1034725cd..0fb4b00e9 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -1,19 +1,16 @@ """Neighbor models for supervised classification.""" -from .base import (NeighborsBase, NeighborsMixin, KNeighborsMixin, - NeighborsClassifierMixin, RadiusNeighborsMixin) + from sklearn.utils.multiclass import check_classification_targets from sklearn.preprocessing import LabelEncoder from sklearn.base import ClassifierMixin, BaseEstimator from sklearn.utils.validation import check_is_fitted as sklearn_check_is_fitted -from sklearn.neighbors import KNeighborsClassifier as _KNeighborsClassifier -from sklearn.neighbors import (RadiusNeighborsClassifier as - _RadiusNeighborsClassifier) - from ..misc.metrics import lp_distance, pairwise_distance from ..exploratory.stats import mean as l2_mean +from .base import (NeighborsBase, NeighborsMixin, KNeighborsMixin, + NeighborsClassifierMixin, RadiusNeighborsMixin) class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, @@ -141,6 +138,9 @@ def _init_estimator(self, sk_metric): Sklearn K Neighbors estimator initialized. """ + from sklearn.neighbors import (KNeighborsClassifier as + _KNeighborsClassifier) + return _KNeighborsClassifier( n_neighbors=self.n_neighbors, weights=self.weights, algorithm=self.algorithm, leaf_size=self.leaf_size, @@ -290,6 +290,9 @@ def _init_estimator(self, sk_metric): Sklearn Radius Neighbors estimator initialized. """ + from sklearn.neighbors import (RadiusNeighborsClassifier as + _RadiusNeighborsClassifier) + return _RadiusNeighborsClassifier( radius=self.radius, weights=self.weights, algorithm=self.algorithm, leaf_size=self.leaf_size, diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index af16b44d2..fbf647735 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -1,21 +1,14 @@ """Neighbor models for regression.""" -from sklearn.neighbors import KNeighborsRegressor as _KNeighborsRegressor -from sklearn.neighbors import (RadiusNeighborsRegressor as - _RadiusNeighborsRegressor) -from sklearn.base import RegressorMixin +from .base import (NeighborsBase, KNeighborsMixin, RadiusNeighborsMixin, + NeighborsRegressorMixin) -from .base import (NeighborsBase, NeighborsMixin, - KNeighborsMixin, RadiusNeighborsMixin, - NeighborsScalarRegresorMixin, - NeighborsFunctionalRegressorMixin, - NearestNeighborsMixinInit) +class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, + KNeighborsMixin): + """Regression based on k-nearest neighbors. -class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, - KNeighborsMixin, RegressorMixin, - NeighborsScalarRegresorMixin): - """Regression based on k-nearest neighbors with scalar response. + Regression with scalar, multivariate or functional response. The target is predicted by local interpolation of the targets associated of the nearest neighbors in the training set. @@ -36,6 +29,11 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, array of distances, and returns an array of the same shape containing the weights. + regressor : callable, optional ((default = + :func:`mean `)) + Function to perform the local regression in the functional response + case. By default used the mean. Can the neighbors of a test sample, + and if weights != 'uniform' an array of weights as second parameter. algorithm : {'auto', 'ball_tree', 'brute'}, optional Algorithm used to compute the nearest neighbors: @@ -69,23 +67,41 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, -------- Firstly, we will create a toy dataset with gaussian-like samples shifted. + >>> from skfda.ml.regression import KNeighborsRegressor >>> from skfda.datasets import make_multimodal_samples >>> from skfda.datasets import make_multimodal_landmarks >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) - >>> y = y.flatten() - >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> y_train = y.flatten() + >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) We will fit a K-Nearest Neighbors regressor to regress a scalar response. - >>> from skfda.ml.regression import KNeighborsScalarRegressor - >>> neigh = KNeighborsScalarRegressor() - >>> neigh.fit(fd, y) + >>> neigh = KNeighborsRegressor() + >>> neigh.fit(X_train, y_train) KNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) We can predict the modes of new samples - >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations - array([ 0.79, 0.27, 0.71, 0.79]) + >>> neigh.predict(X_test).round(2) # Predict test data + array([0.38, 0.14, 0.27, 0.52, 0.38]) + + + Now we will create a functional response to train the model + + >>> y_train = 5 * X_train + 1 + >>> y_train + FDataGrid(...) + + We train the estimator with the functional response + + >>> neigh.fit(X_train, y_train) + KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + + And predict the responses as in the first case. + + >>> neigh.predict(X_test) + FDataGrid(...) See also -------- @@ -112,18 +128,19 @@ class KNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, """ - def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', - leaf_size=30, metric='l2', metric_params=None, - n_jobs=1, sklearn_metric=False): - """Initialize the classifier.""" + def __init__(self, n_neighbors=5, weights='uniform', regressor='mean', + algorithm='auto', leaf_size=30, metric='l2', + metric_params=None, n_jobs=1, sklearn_metric=False): + """Initialize the regressor.""" super().__init__(n_neighbors=n_neighbors, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, sklearn_metric=sklearn_metric) + self.regressor = regressor - def _init_estimator(self, sk_metric): + def _init_multivariate_estimator(self, sk_metric): """Initialize the sklearn K neighbors estimator. Args: @@ -135,17 +152,25 @@ def _init_estimator(self, sk_metric): Sklearn K Neighbors estimator initialized. """ + from sklearn.neighbors import (KNeighborsRegressor as + _KNeighborsRegressor) + return _KNeighborsRegressor( n_neighbors=self.n_neighbors, weights=self.weights, algorithm=self.algorithm, leaf_size=self.leaf_size, metric=sk_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) + def _query(self, X): + """Return distances and neighbors of given sample.""" + return self.estimator_.kneighbors(X) + -class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, - RadiusNeighborsMixin, RegressorMixin, - NeighborsScalarRegresorMixin): - """Scalar regression based on neighbors within a fixed radius. +class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, + RadiusNeighborsMixin): + """Regression based on neighbors within a fixed radius. + + Regression with scalar, multivariate or functional response. The target is predicted by local interpolation of the targets associated of the nearest neighbors in the training set. @@ -168,6 +193,11 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, containing the weights. Uniform weights are used by default. + regressor : callable, optional ((default = + :func:`mean `)) + Function to perform the local regression in the functional response + case. By default used the mean. Can the neighbors of a test sample, + and if weights != 'uniform' an array of weights as second parameter. algorithm : {'auto', 'ball_tree', 'brute'}, optional Algorithm used to compute the nearest neighbors: @@ -188,6 +218,9 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, for a list of available metrics. metric_params : dict, optional (default = None) Additional keyword arguments for the metric function. + outlier_response : :class:`FData`, optional (default = None) + Default response in the functional response case for test samples + without neighbors. n_jobs : int or None, optional (default=None) The number of parallel jobs to run for neighbors search. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. @@ -196,155 +229,44 @@ class RadiusNeighborsScalarRegressor(NeighborsBase, NeighborsMixin, Indicates if the metric used is a sklearn distance between vectors (see :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the module :mod:`skfda.misc.metrics`. + Examples -------- Firstly, we will create a toy dataset with gaussian-like samples shifted. + >>> from skfda.ml.regression import RadiusNeighborsRegressor >>> from skfda.datasets import make_multimodal_samples >>> from skfda.datasets import make_multimodal_landmarks >>> y = make_multimodal_landmarks(n_samples=30, std=.5, random_state=0) - >>> y = y.flatten() - >>> fd = make_multimodal_samples(n_samples=30, std=.5, random_state=0) - - - We will fit a K-Nearest Neighbors regressor to regress a scalar response. - - >>> from skfda.ml.regression import RadiusNeighborsScalarRegressor - >>> neigh = RadiusNeighborsScalarRegressor(radius=.2) - >>> neigh.fit(fd, y) - RadiusNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) - - We can predict the modes of new samples. - - >>> neigh.predict(fd[:4]).round(2) # Predict first 4 locations - array([ 0.84, 0.27, 0.66, 0.79]) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.RadiusNeighborsClassifier`. - - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm - - """ - - def __init__(self, radius=1.0, weights='uniform', algorithm='auto', - leaf_size=30, metric='l2', metric_params=None, - n_jobs=1, sklearn_metric=False): - """Initialize the classifier.""" - - super().__init__(radius=radius, weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - - def _init_estimator(self, sk_metric): - """Initialize the sklearn radius neighbors estimator. - - Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with - sklearn API or matrix (n_samples, n_samples) with precomputed - distances. - - Returns: - Sklearn Radius Neighbors estimator initialized. - - """ - return _RadiusNeighborsRegressor( - radius=self.radius, weights=self.weights, - algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, - n_jobs=self.n_jobs) - - -class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, - NeighborsBase, KNeighborsMixin, - NeighborsFunctionalRegressorMixin): - """Functional regression based on neighbors within a fixed radius. + >>> y_train = y.flatten() + >>> X_train = make_multimodal_samples(n_samples=30, std=.5, random_state=0) + >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. + We will fit a Radius-Nearest Neighbors regressor to regress a scalar + response. - Parameters - ---------- - n_neighbors : int, optional (default = 5) - Number of neighbors to use by default for :meth:`kneighbors` queries. - weights : str or callable - weight function used in prediction. Possible values: + >>> neigh = RadiusNeighborsRegressor(radius=0.2) + >>> neigh.fit(X_train, y_train) + KNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. + We can predict the modes of new samples - Uniform weights are used by default. - regressor : callable, optional ((default = - :func:`mean `)) - Function to perform the local regression. By default used the mean. Can - accept a user-defined function wich accepts a :class:`FDataGrid` with - the neighbors of a test sample, and if weights != 'uniform' an array - of weights as second parameter. - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: + >>> neigh.predict(X_test).round(2) # Predict test data + array([0.39, 0.07, 0.26, 0.5 , 0.46]) - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm - based on the values passed to :meth:`fit` method. - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the L2 distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of - the module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with gaussian-like samples shifted, - and we will try to predict 5 X +1. + Now we will create a functional response to train the model - >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.05, - ... random_state=0) >>> y_train = 5 * X_train + 1 - >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) + >>> y_train + FDataGrid(...) - We will fit a K-Nearest Neighbors functional regressor. + We train the estimator with the functional response - >>> from skfda.ml.regression import KNeighborsFunctionalRegressor - >>> neigh = KNeighborsFunctionalRegressor() >>> neigh.fit(X_train, y_train) KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) - We can predict the response of new samples. + And predict the responses as in the first case. >>> neigh.predict(X_test) FDataGrid(...) @@ -368,140 +290,39 @@ class KNeighborsFunctionalRegressor(NearestNeighborsMixinInit, """ - def __init__(self, n_neighbors=5, weights='uniform', regressor='mean', + def __init__(self, radius=1.0, weights='uniform', regressor='mean', algorithm='auto', leaf_size=30, metric='l2', - metric_params=None, n_jobs=1, sklearn_metric=False): + metric_params=None, outlier_response=None, n_jobs=1, + sklearn_metric=False): """Initialize the classifier.""" - super().__init__(n_neighbors=n_neighbors, radius=1., - weights=weights, algorithm=algorithm, + super().__init__(radius=radius, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, sklearn_metric=sklearn_metric) self.regressor = regressor + self.outlier_response = outlier_response - def _query(self, X): - """Return distances and neighbors of given sample""" - return self.estimator_.kneighbors(X) - - -class RadiusNeighborsFunctionalRegressor(NearestNeighborsMixinInit, - NeighborsBase, RadiusNeighborsMixin, - NeighborsFunctionalRegressorMixin): - """Functional regression based on neighbors within a fixed radius. - - The target is predicted by local interpolation of the targets - associated of the nearest neighbors in the training set. - - Parameters - ---------- - radius : float, optional (default = 1.0) - Range of parameter space to use by default for :meth:`radius_neighbors` - queries. - weights : str or callable - weight function used in prediction. Possible values: - - - 'uniform' : uniform weights. All points in each neighborhood - are weighted equally. - - 'distance' : weight points by the inverse of their distance. - in this case, closer neighbors of a query point will have a - greater influence than neighbors which are further away. - - [callable] : a user-defined function which accepts an - array of distances, and returns an array of the same shape - containing the weights. - - Uniform weights are used by default. - regressor : callable, optional ((default = - :func:`mean `)) - Function to perform the local regression. By default used the mean. Can - accept a user-defined function wich accepts a :class:`FDataGrid` with - the neighbors of a test sample, and if weights != 'uniform' an array - of weights as second parameter. - algorithm : {'auto', 'ball_tree', 'brute'}, optional - Algorithm used to compute the nearest neighbors: - - - 'ball_tree' will use :class:`sklearn.neighbors.BallTree`. - - 'brute' will use a brute-force search. - - 'auto' will attempt to decide the most appropriate algorithm - based on the values passed to :meth:`fit` method. - - leaf_size : int, optional (default = 30) - Leaf size passed to BallTree. This can affect the - speed of the construction and query, as well as the memory - required to store the tree. The optimal value depends on the - nature of the problem. - metric : string or callable, (default - :func:`lp_distance `) - the distance metric to use for the tree. The default metric is - the L2 distance. See the documentation of the metrics module - for a list of available metrics. - metric_params : dict, optional (default = None) - Additional keyword arguments for the metric function. - outlier_response : :class:`FDataGrid`, optional (default = None) - Default response for test samples without neighbors. - n_jobs : int or None, optional (default=None) - The number of parallel jobs to run for neighbors search. - ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. - ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) - Indicates if the metric used is a sklearn distance between vectors (see - :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of - the module :mod:`skfda.misc.metrics`. - Examples - -------- - Firstly, we will create a toy dataset with gaussian-like samples shifted, - and we will try to predict the response 5 X +1. - - >>> from skfda.datasets import make_multimodal_samples - >>> X_train = make_multimodal_samples(n_samples=30, std=.05, - ... random_state=0) - >>> y_train = 5 * X_train + 1 - >>> X_test = make_multimodal_samples(n_samples=5, std=.05, random_state=0) - - We will fit a Radius Nearest Neighbors functional regressor. - - >>> from skfda.ml.regression import RadiusNeighborsFunctionalRegressor - >>> neigh = RadiusNeighborsFunctionalRegressor(radius=.03) - >>> neigh.fit(X_train, y_train) - RadiusNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) - - We can predict the response of new samples. - - >>> neigh.predict(X_test) - FDataGrid(...) - - See also - -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsScalarRegressor - NearestNeighbors - NearestCentroids - Notes - ----- - See Nearest Neighbors in the sklearn online documentation for a discussion - of the choice of ``algorithm`` and ``leaf_size``. - - This class wraps the sklearn classifier - `sklearn.neighbors.RadiusNeighborsClassifier`. + def _init_multivariate_estimator(self, sk_metric): + """Initialize the sklearn radius neighbors estimator. - https://en.wikipedia.org/wiki/K-nearest_neighbor_algorithm + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. - """ + Returns: + Sklearn Radius Neighbors estimator initialized. - def __init__(self, radius=1., weights='uniform', regressor='mean', - algorithm='auto', leaf_size=30, metric='l2', - metric_params=None, outlier_response=None, n_jobs=1, - sklearn_metric=False): - """Initialize the classifier.""" + """ + from sklearn.neighbors import (RadiusNeighborsRegressor as + _RadiusNeighborsRegressor) - super().__init__(n_neighbors=5, radius=radius, - weights=weights, algorithm=algorithm, - leaf_size=leaf_size, metric=metric, - metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) - self.regressor = regressor - self.outlier_response = outlier_response + return _RadiusNeighborsRegressor( + radius=self.radius, weights=self.weights, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) def _query(self, X): """Return distances and neighbors of given sample""" diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index 1a103cb74..c83c741a7 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -1,11 +1,11 @@ """Unsupervised learner for implementing neighbor searches.""" -from .base import (NearestNeighborsMixinInit, NeighborsBase, NeighborsMixin, - KNeighborsMixin, RadiusNeighborsMixin, _to_sklearn_metric) +from .base import (NeighborsBase, NeighborsMixin, KNeighborsMixin, + RadiusNeighborsMixin) -class NearestNeighbors(NearestNeighborsMixinInit, NeighborsBase, - NeighborsMixin, KNeighborsMixin, RadiusNeighborsMixin): +class NearestNeighbors(NeighborsBase, NeighborsMixin, KNeighborsMixin, + RadiusNeighborsMixin): """Unsupervised learner for implementing neighbor searches. Parameters @@ -108,3 +108,23 @@ def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, sklearn_metric=sklearn_metric) + + def _init_estimator(self, sk_metric): + """Initialize the sklearn nearest neighbors estimator. + + Args: + sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn API or matrix (n_samples, n_samples) with precomputed + distances. + + Returns: + Sklearn K Neighbors estimator initialized. + + """ + from sklearn.neighbors import NearestNeighbors as _NearestNeighbors + + return _NearestNeighbors( + n_neighbors=self.n_neighbors, radius=self.radius, + algorithm=self.algorithm, leaf_size=self.leaf_size, + metric=sk_metric, metric_params=self.metric_params, + n_jobs=self.n_jobs) diff --git a/skfda/ml/regression/__init__.py b/skfda/ml/regression/__init__.py index 00f57c771..c2a67127a 100644 --- a/skfda/ml/regression/__init__.py +++ b/skfda/ml/regression/__init__.py @@ -1,8 +1,4 @@ -from ..._neighbors import (KNeighborsScalarRegressor, - RadiusNeighborsScalarRegressor, - KNeighborsFunctionalRegressor, - RadiusNeighborsFunctionalRegressor) - +from ..._neighbors import KNeighborsRegressor, RadiusNeighborsRegressor from .linear_model import LinearScalarRegression diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 354251eba..478deaccd 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -10,10 +10,7 @@ RadiusNeighborsClassifier, NearestCentroids) from skfda.ml.clustering import NearestNeighbors -from skfda.ml.regression import (KNeighborsScalarRegressor, - RadiusNeighborsScalarRegressor, - KNeighborsFunctionalRegressor, - RadiusNeighborsFunctionalRegressor) +from skfda.ml.regression import KNeighborsRegressor, RadiusNeighborsRegressor from skfda.representation.basis import Fourier @@ -22,8 +19,9 @@ class TestNeighbors(unittest.TestCase): def setUp(self): """Creates test data""" random_state = np.random.RandomState(0) - modes_location = np.concatenate((random_state.normal(-.3, .04, size=15), - random_state.normal(.3, .04, size=15))) + modes_location = np.concatenate( + (random_state.normal(-.3, .04, size=15), + random_state.normal(.3, .04, size=15))) idx = np.arange(30) random_state.shuffle(idx) @@ -71,8 +69,8 @@ def test_predict_regressor(self): # Dummy test, with weight = distance, only the sample with distance 0 # will be returned, obtaining the exact location - knnr = KNeighborsScalarRegressor(weights='distance') - rnnr = RadiusNeighborsScalarRegressor(weights='distance', radius=.1) + knnr = KNeighborsRegressor(weights='distance') + rnnr = RadiusNeighborsRegressor(weights='distance', radius=.1) knnr.fit(self.X, self.modes_location) rnnr.fit(self.X, self.modes_location) @@ -83,6 +81,7 @@ def test_predict_regressor(self): self.modes_location) def test_kneighbors(self): + """Test k neighbor searches for all k-neighbors estimators""" nn = NearestNeighbors() nn.fit(self.X) @@ -90,7 +89,7 @@ def test_kneighbors(self): knn = KNeighborsClassifier() knn.fit(self.X, self.y) - knnr = KNeighborsScalarRegressor() + knnr = KNeighborsRegressor() knnr.fit(self.X, self.modes_location) for neigh in [nn, knn, knnr]: @@ -120,7 +119,7 @@ def test_radius_neighbors(self): knn = RadiusNeighborsClassifier(radius=.1) knn.fit(self.X, self.y) - knnr = RadiusNeighborsScalarRegressor(radius=.1) + knnr = RadiusNeighborsRegressor(radius=.1) knnr.fit(self.X, self.modes_location) for neigh in [nn, knn, knnr]: @@ -143,7 +142,7 @@ def test_radius_neighbors(self): self.assertEqual(graph[0, i] == 0.0, i not in links[0]) def test_knn_functional_response(self): - knnr = KNeighborsFunctionalRegressor(n_neighbors=1) + knnr = KNeighborsRegressor(n_neighbors=1) knnr.fit(self.X, self.X) @@ -153,7 +152,7 @@ def test_knn_functional_response(self): def test_knn_functional_response_sklearn(self): # Check sklearn metric - knnr = KNeighborsFunctionalRegressor(n_neighbors=1, metric='euclidean', + knnr = KNeighborsRegressor(n_neighbors=1, metric='euclidean', sklearn_metric=True) knnr.fit(self.X, self.X) @@ -162,7 +161,7 @@ def test_knn_functional_response_sklearn(self): self.X.data_matrix) def test_knn_functional_response_precomputed(self): - knnr = KNeighborsFunctionalRegressor(n_neighbors=4, weights='distance', + knnr = KNeighborsRegressor(n_neighbors=4, weights='distance', metric='precomputed') d = pairwise_distance(lp_distance) distances = d(self.X[:4], self.X[:4]) @@ -174,7 +173,7 @@ def test_knn_functional_response_precomputed(self): self.X[:4].data_matrix) def test_radius_functional_response(self): - knnr = RadiusNeighborsFunctionalRegressor(metric=lp_distance, + knnr = RadiusNeighborsRegressor(metric=lp_distance, weights='distance', regressor=l2_mean) @@ -190,7 +189,7 @@ def weights(weights): return np.array([w == 0 for w in weights], dtype=float) - knnr = KNeighborsFunctionalRegressor(weights=weights, n_neighbors=5) + knnr = KNeighborsRegressor(weights=weights, n_neighbors=5) response = self.X.to_basis(Fourier(domain_range=(-1, 1), nbasis=10)) knnr.fit(self.X, response) @@ -200,7 +199,7 @@ def weights(weights): def test_functional_regression_distance_weights(self): - knnr = KNeighborsFunctionalRegressor( + knnr = KNeighborsRegressor( weights='distance', n_neighbors=10) knnr.fit(self.X[:10], self.X[:10]) res = knnr.predict(self.X[11]) @@ -216,7 +215,7 @@ def test_functional_regression_distance_weights(self): response.data_matrix) def test_functional_response_basis(self): - knnr = KNeighborsFunctionalRegressor(weights='distance', n_neighbors=5) + knnr = KNeighborsRegressor(weights='distance', n_neighbors=5) response = self.X.to_basis(Fourier(domain_range=(-1, 1), nbasis=10)) knnr.fit(self.X, response) @@ -225,7 +224,7 @@ def test_functional_response_basis(self): response.coefficients) def test_radius_outlier_functional_response(self): - knnr = RadiusNeighborsFunctionalRegressor(radius=0.001) + knnr = RadiusNeighborsRegressor(radius=0.001) knnr.fit(self.X[3:6], self.X[3:6]) # No value given @@ -233,7 +232,7 @@ def test_radius_outlier_functional_response(self): knnr.predict(self.X[:10]) # Test response - knnr = RadiusNeighborsFunctionalRegressor(radius=0.001, + knnr = RadiusNeighborsRegressor(radius=0.001, outlier_response=self.X[0]) knnr.fit(self.X[:6], self.X[:6]) @@ -255,7 +254,7 @@ def test_nearest_centroids_exceptions(self): def test_functional_regressor_exceptions(self): - knnr = RadiusNeighborsFunctionalRegressor() + knnr = RadiusNeighborsRegressor() with np.testing.assert_raises(ValueError): knnr.fit(self.X[:3], self.X[:4]) @@ -285,7 +284,7 @@ def test_search_neighbors_sklearn(self): def test_score_functional_response(self): - neigh = KNeighborsFunctionalRegressor() + neigh = KNeighborsRegressor() y = 5 * self.X + 1 neigh.fit(self.X, y) @@ -301,7 +300,7 @@ def test_score_functional_response(self): np.testing.assert_almost_equal(r, 0.9982527586114364) def test_score_functional_response_exceptions(self): - neigh = RadiusNeighborsFunctionalRegressor() + neigh = RadiusNeighborsRegressor() neigh.fit(self.X, self.X) with np.testing.assert_raises(ValueError): @@ -309,7 +308,7 @@ def test_score_functional_response_exceptions(self): def test_multivariate_response_score(self): - neigh = RadiusNeighborsFunctionalRegressor() + neigh = RadiusNeighborsRegressor() y = make_multimodal_samples(n_samples=5, dim_domain=2, random_state=0) neigh.fit(self.X[:5], y) From ae3350750b02166934752e6d9222426fc0a1dbc2 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 7 Sep 2019 00:24:09 +0200 Subject: [PATCH 202/222] Rename sklearn_metric -> multivariate_metric --- skfda/_neighbors/base.py | 22 +++++++++++----------- skfda/_neighbors/classification.py | 12 ++++++------ skfda/_neighbors/regression.py | 12 ++++++------ skfda/_neighbors/unsupervised.py | 9 +++++---- tests/test_neighbors.py | 4 ++-- 5 files changed, 30 insertions(+), 29 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index ac16fbbd5..29a482ce7 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -45,7 +45,7 @@ def _from_multivariate(data_matrix, sample_points, shape, **kwargs): return FDataGrid(data_matrix.reshape(shape), sample_points, **kwargs) -def _to_sklearn_metric(metric, sample_points): +def _to_multivariate_metric(metric, sample_points): r"""Transform a metric between FDatagrid in a sklearn compatible one. Given a metric between FDatagrids returns a compatible metric used to @@ -65,7 +65,7 @@ def _to_sklearn_metric(metric, sample_points): >>> import numpy as np >>> from skfda import FDataGrid >>> from skfda.misc.metrics import lp_distance - >>> from skfda._neighbors.base import _to_sklearn_metric + >>> from skfda._neighbors.base import _to_multivariate_metric Calculate the Lp distance between fd and fd2. @@ -77,7 +77,7 @@ def _to_sklearn_metric(metric, sample_points): Creation of the sklearn-style metric. - >>> sklearn_lp_distance = _to_sklearn_metric(lp_distance, [x]) + >>> sklearn_lp_distance = _to_multivariate_metric(lp_distance, [x]) >>> sklearn_lp_distance(np.ones(len(x)), np.zeros(len(x))).round(2) 1.0 @@ -85,13 +85,13 @@ def _to_sklearn_metric(metric, sample_points): # Shape -> (n_samples = 1, domain_dims...., image_dimension (-1)) shape = [1] + [len(axis) for axis in sample_points] + [-1] - def sklearn_metric(x, y, _check=False, **kwargs): + def multivariate_metric(x, y, _check=False, **kwargs): return metric(_from_multivariate(x, sample_points, shape), _from_multivariate(y, sample_points, shape), _check=_check, **kwargs) - return sklearn_metric + return multivariate_metric class NeighborsBase(ABC, BaseEstimator): @@ -101,7 +101,7 @@ class NeighborsBase(ABC, BaseEstimator): def __init__(self, n_neighbors=None, radius=None, weights='uniform', algorithm='auto', leaf_size=30, metric='l2', metric_params=None, - n_jobs=None, sklearn_metric=False): + n_jobs=None, multivariate_metric=False): self.n_neighbors = n_neighbors self.radius = radius @@ -111,7 +111,7 @@ def __init__(self, n_neighbors=None, radius=None, self.metric = metric self.metric_params = metric_params self.n_jobs = n_jobs - self.sklearn_metric = sklearn_metric + self.multivariate_metric = multivariate_metric def _check_is_fitted(self): """Check if the estimator is fitted. @@ -160,13 +160,13 @@ def fit(self, X, y=None): self._sample_points = X.sample_points self._shape = X.data_matrix.shape[1:] - if not self.sklearn_metric: + if not self.multivariate_metric: # Constructs sklearn metric to manage vector if self.metric == 'l2': metric = lp_distance else: metric = self.metric - sk_metric = _to_sklearn_metric(metric, self._sample_points) + sk_metric = _to_multivariate_metric(metric, self._sample_points) else: sk_metric = self.metric @@ -482,14 +482,14 @@ def _functional_fit(self, X, y): self._sample_points = X.sample_points self._shape = X.data_matrix.shape[1:] - if not self.sklearn_metric: + if not self.multivariate_metric: if self.metric == 'l2': metric = lp_distance else: metric = self.metric - sk_metric = _to_sklearn_metric(metric, self._sample_points) + sk_metric = _to_multivariate_metric(metric, self._sample_points) else: sk_metric = self.metric diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 0fb4b00e9..6d42894d4 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -58,7 +58,7 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. Doesn't affect :meth:`fit` method. - sklearn_metric : boolean, optional (default = False) + multivariate_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the module :mod:`skfda.misc.metrics`. @@ -117,14 +117,14 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', leaf_size=30, metric='l2', metric_params=None, - n_jobs=1, sklearn_metric=False): + n_jobs=1, multivariate_metric=False): """Initialize the classifier.""" super().__init__(n_neighbors=n_neighbors, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) + multivariate_metric=multivariate_metric) def _init_estimator(self, sk_metric): """Initialize the sklearn K neighbors estimator. @@ -219,7 +219,7 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, The number of parallel jobs to run for neighbors search. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) + multivariate_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the module :mod:`skfda.misc.metrics`. @@ -268,13 +268,13 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, def __init__(self, radius=1.0, weights='uniform', algorithm='auto', leaf_size=30, metric='l2', metric_params=None, - outlier_label=None, n_jobs=1, sklearn_metric=False): + outlier_label=None, n_jobs=1, multivariate_metric=False): """Initialize the classifier.""" super().__init__(radius=radius, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) + multivariate_metric=multivariate_metric) self.outlier_label = outlier_label diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index fbf647735..9ae093fda 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -59,7 +59,7 @@ class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. Doesn't affect :meth:`fit` method. - sklearn_metric : boolean, optional (default = False) + multivariate_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the module :mod:`skfda.misc.metrics`. @@ -130,14 +130,14 @@ class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, def __init__(self, n_neighbors=5, weights='uniform', regressor='mean', algorithm='auto', leaf_size=30, metric='l2', - metric_params=None, n_jobs=1, sklearn_metric=False): + metric_params=None, n_jobs=1, multivariate_metric=False): """Initialize the regressor.""" super().__init__(n_neighbors=n_neighbors, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) + multivariate_metric=multivariate_metric) self.regressor = regressor def _init_multivariate_estimator(self, sk_metric): @@ -225,7 +225,7 @@ class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, The number of parallel jobs to run for neighbors search. ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. - sklearn_metric : boolean, optional (default = False) + multivariate_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the module :mod:`skfda.misc.metrics`. @@ -293,13 +293,13 @@ class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, def __init__(self, radius=1.0, weights='uniform', regressor='mean', algorithm='auto', leaf_size=30, metric='l2', metric_params=None, outlier_response=None, n_jobs=1, - sklearn_metric=False): + multivariate_metric=False): """Initialize the classifier.""" super().__init__(radius=radius, weights=weights, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, n_jobs=n_jobs, - sklearn_metric=sklearn_metric) + multivariate_metric=multivariate_metric) self.regressor = regressor self.outlier_response = outlier_response diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index c83c741a7..1b87991a3 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -40,7 +40,7 @@ class NearestNeighbors(NeighborsBase, NeighborsMixin, KNeighborsMixin, ``None`` means 1 unless in a :obj:`joblib.parallel_backend` context. ``-1`` means using all processors. Doesn't affect :meth:`fit` method. - sklearn_metric : boolean, optional (default = False) + multivariate_metric : boolean, optional (default = False) Indicates if the metric used is a sklearn distance between vectors (see :class:`sklearn.neighbors.DistanceMetric`) or a functional metric of the module :mod:`skfda.misc.metrics`. @@ -101,13 +101,14 @@ class NearestNeighbors(NeighborsBase, NeighborsMixin, KNeighborsMixin, def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', leaf_size=30, metric='l2', metric_params=None, - n_jobs=1, sklearn_metric=False): + n_jobs=1, multivariate_metric=False): """Initialize the nearest neighbors searcher.""" super().__init__(n_neighbors=n_neighbors, radius=radius, algorithm=algorithm, leaf_size=leaf_size, metric=metric, metric_params=metric_params, - n_jobs=n_jobs, sklearn_metric=sklearn_metric) + n_jobs=n_jobs, + multivariate_metric=multivariate_metric) def _init_estimator(self, sk_metric): """Initialize the sklearn nearest neighbors estimator. @@ -122,7 +123,7 @@ def _init_estimator(self, sk_metric): """ from sklearn.neighbors import NearestNeighbors as _NearestNeighbors - + return _NearestNeighbors( n_neighbors=self.n_neighbors, radius=self.radius, algorithm=self.algorithm, leaf_size=self.leaf_size, diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 478deaccd..0fb46ff6c 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -153,7 +153,7 @@ def test_knn_functional_response(self): def test_knn_functional_response_sklearn(self): # Check sklearn metric knnr = KNeighborsRegressor(n_neighbors=1, metric='euclidean', - sklearn_metric=True) + multivariate_metric=True) knnr.fit(self.X, self.X) res = knnr.predict(self.X) @@ -273,7 +273,7 @@ def test_search_neighbors_precomputed(self): def test_search_neighbors_sklearn(self): - nn = NearestNeighbors(metric='euclidean', sklearn_metric=True, + nn = NearestNeighbors(metric='euclidean', multivariate_metric=True, n_neighbors=2) nn.fit(self.X[:4], self.y[:4]) From 7a3ad9d4da72d1b9fe5074c15a01a471ccffb94d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Sat, 7 Sep 2019 11:21:55 +0200 Subject: [PATCH 203/222] Update examples/plot_landmark_registration.py Co-Authored-By: Pablo Marcos --- examples/plot_landmark_registration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/plot_landmark_registration.py b/examples/plot_landmark_registration.py index 3f754b7c1..1d67d58e9 100644 --- a/examples/plot_landmark_registration.py +++ b/examples/plot_landmark_registration.py @@ -83,7 +83,6 @@ for i in range(fd.n_samples): fig.axes[0].scatter([-0.5, 0.5], landmarks[i]) -fig ############################################################################## # From 3ce5f6ed59b68dbdfb4fd272c9fa5c5396394458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Sat, 7 Sep 2019 11:22:02 +0200 Subject: [PATCH 204/222] Update examples/plot_landmark_registration.py Co-Authored-By: Pablo Marcos --- examples/plot_landmark_registration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/plot_landmark_registration.py b/examples/plot_landmark_registration.py index 1d67d58e9..2555d1922 100644 --- a/examples/plot_landmark_registration.py +++ b/examples/plot_landmark_registration.py @@ -95,7 +95,6 @@ fig.axes[0].scatter([-0.5, 0.5], [1, 1]) -fig ############################################################################## # From 8ee346ebf60bf06c6bb8c130e31aa7ba36556224 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Sat, 7 Sep 2019 11:22:08 +0200 Subject: [PATCH 205/222] Update examples/plot_pairwise_alignment.py Co-Authored-By: Pablo Marcos --- examples/plot_pairwise_alignment.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/plot_pairwise_alignment.py b/examples/plot_pairwise_alignment.py index b547f2b25..de20eed0a 100644 --- a/examples/plot_pairwise_alignment.py +++ b/examples/plot_pairwise_alignment.py @@ -193,7 +193,6 @@ labels = fig.axes[0].get_lines() fig.axes[0].legend(handles=[labels[0], labels[-1]]) -fig ############################################################################## # The following figure shows the result of the pairwise alignment of From 3764c92876961f470f1638e935e0fc9c6f53e1df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Sat, 7 Sep 2019 11:22:16 +0200 Subject: [PATCH 206/222] Update examples/plot_pairwise_alignment.py Co-Authored-By: Pablo Marcos --- examples/plot_pairwise_alignment.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/plot_pairwise_alignment.py b/examples/plot_pairwise_alignment.py index de20eed0a..2f23dbbe3 100644 --- a/examples/plot_pairwise_alignment.py +++ b/examples/plot_pairwise_alignment.py @@ -160,7 +160,6 @@ # Plots identity fig.axes[0].plot(t, t, color='C0', linestyle="--") -fig ############################################################################## From 90a57deae2161c7e4566ef2a94b19ab5ad122718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Sat, 7 Sep 2019 11:22:24 +0200 Subject: [PATCH 207/222] Update examples/plot_pairwise_alignment.py Co-Authored-By: Pablo Marcos --- examples/plot_pairwise_alignment.py | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/plot_pairwise_alignment.py b/examples/plot_pairwise_alignment.py index 2f23dbbe3..1bf27c482 100644 --- a/examples/plot_pairwise_alignment.py +++ b/examples/plot_pairwise_alignment.py @@ -141,7 +141,6 @@ # Legend fig.axes[0].legend() -fig ############################################################################## From 793cab5a776235179315d2dea32a279570935d86 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 7 Sep 2019 12:14:01 +0200 Subject: [PATCH 208/222] Unify neighbors regressors --- docs/modules/ml/classification.rst | 7 +++---- docs/modules/ml/clustering.rst | 2 +- docs/modules/ml/regression.rst | 13 +++++-------- examples/plot_k_neighbors_classification.py | 4 ++-- .../plot_neighbors_functional_regression.py | 12 ++++++------ examples/plot_neighbors_scalar_regression.py | 18 +++++++++--------- .../plot_radius_neighbors_classification.py | 2 +- skfda/_neighbors/classification.py | 13 +++++++------ skfda/_neighbors/regression.py | 16 ++++++++-------- skfda/_neighbors/unsupervised.py | 4 ++-- 10 files changed, 44 insertions(+), 47 deletions(-) diff --git a/docs/modules/ml/classification.rst b/docs/modules/ml/classification.rst index fb4e15f3d..9524a4aea 100644 --- a/docs/modules/ml/classification.rst +++ b/docs/modules/ml/classification.rst @@ -11,10 +11,9 @@ Nearest Neighbors This module contains `nearest neighbors `_ estimators to -perform classification. In the examples `K-nearest neighbors classification -<../../../auto_examples/plot_k_neighbors_classification.html>`_ and -`Radius neighbors classification -<../../../auto_examples/plot_radius_neighbors_classification.html>`_ +perform classification. In the examples +:ref:`sphx_glr_auto_examples_plot_k_neighbors_classification.py` and +:ref:`sphx_glr_auto_examples_plot_radius_neighbors_classification.py` it is explained the basic usage of these estimators. .. autosummary:: diff --git a/docs/modules/ml/clustering.rst b/docs/modules/ml/clustering.rst index 63979165d..ce07f534b 100644 --- a/docs/modules/ml/clustering.rst +++ b/docs/modules/ml/clustering.rst @@ -13,7 +13,7 @@ The following classes implement both, the K-Means and the Fuzzy K-Means algorithms respectively. In order to show the results in a visual way, the module :mod:`skfda.exploratory.visualization.clustering_plots ` can be used. -See the `Clustering Example <../auto_examples/plot_clustering.html>`_ for a +See the example :ref:`sphx_glr_auto_examples_plot_clustering.py` for a detailed explanation. .. autosummary:: diff --git a/docs/modules/ml/regression.rst b/docs/modules/ml/regression.rst index 0191022b8..72ba60f4b 100644 --- a/docs/modules/ml/regression.rst +++ b/docs/modules/ml/regression.rst @@ -20,16 +20,13 @@ Nearest Neighbors This module contains `nearest neighbors `_ estimators to -perform regression. In the examples `Neighbors Scalar Regression -<../../../auto_examples/plot_neighbors_scalar_regression.html>`_ and -`Neighbors Functional Regression -<../../../auto_examples/plot_neighbors_functional_regression.html>`_ +perform regression. In the examples +:ref:`sphx_glr_auto_examples_plot_neighbors_scalar_regression.py` and +:ref:`sphx_glr_auto_examples_plot_neighbors_functional_regression.py` it is explained the basic usage of these estimators. .. autosummary:: :toctree: autosummary - skfda.ml.regression.KNeighborsScalarRegressor - skfda.ml.regression.RadiusNeighborsScalarRegressor - skfda.ml.regression.KNeighborsFunctionalRegressor - skfda.ml.regression.RadiusNeighborsFunctionalRegressor + skfda.ml.regression.KNeighborsRegressor + skfda.ml.regression.RadiusNeighborsRegressor diff --git a/examples/plot_k_neighbors_classification.py b/examples/plot_k_neighbors_classification.py index 494cff4e2..d1633cbac 100644 --- a/examples/plot_k_neighbors_classification.py +++ b/examples/plot_k_neighbors_classification.py @@ -172,7 +172,7 @@ # functional one, due to the constant multiplication do no affect the # order of the neighbors. # -# Setting the parameter ``sklearn_metric`` of the classifier to True, +# Setting the parameter ``multivariate_metric`` of the classifier to True, # a vectorial metric of sklearn can be passed. In # :class:`sklearn.neighbors.DistanceMetric` there are listed all the metrics # supported. @@ -182,7 +182,7 @@ # the integration, but the result should be similar. # -knn = KNeighborsClassifier(metric='euclidean', sklearn_metric=True) +knn = KNeighborsClassifier(metric='euclidean', multivariate_metric=True) gscv2 = GridSearchCV(knn, param_grid, cv=ss) gscv2.fit(X, y) diff --git a/examples/plot_neighbors_functional_regression.py b/examples/plot_neighbors_functional_regression.py index 079981871..39f460176 100644 --- a/examples/plot_neighbors_functional_regression.py +++ b/examples/plot_neighbors_functional_regression.py @@ -14,7 +14,7 @@ import matplotlib.pyplot as plt import numpy as np from sklearn.model_selection import train_test_split -from skfda.ml.regression import KNeighborsFunctionalRegressor +from skfda.ml.regression import KNeighborsRegressor from skfda.representation.basis import Fourier @@ -22,10 +22,10 @@ # # In this example we are going to show the usage of the nearest neighbors # regressors with functional response. There is available a K-nn version, -# :class:`KNeighborsFunctionalRegressor -# `, and other one based in -# the radius, :class:`RadiusNeighborsFunctionalRegressor -# `. +# :class:`KNeighborsRegressor +# `, and other one based in +# the radius, :class:`RadiusNeighborsRegressor +# `. # # # As in the scalar response example, we will fetch the caniadian weather @@ -80,7 +80,7 @@ # -knn = KNeighborsFunctionalRegressor(n_neighbors=5, weights='distance') +knn = KNeighborsRegressor(n_neighbors=5, weights='distance') knn.fit(X_train, y_train) ################################################################################ diff --git a/examples/plot_neighbors_scalar_regression.py b/examples/plot_neighbors_scalar_regression.py index 8558c79e5..c9ed53904 100644 --- a/examples/plot_neighbors_scalar_regression.py +++ b/examples/plot_neighbors_scalar_regression.py @@ -14,17 +14,17 @@ import matplotlib.pyplot as plt import numpy as np from sklearn.model_selection import train_test_split, GridSearchCV, KFold -from skfda.ml.regression import KNeighborsScalarRegressor +from skfda.ml.regression import KNeighborsRegressor ################################################################################ # # In this example, we are going to show the usage of the nearest neighbors # regressors with scalar response. There is available a K-nn version, -# :class:`KNeighborsScalarRegressor -# `, and other one based in the -# radius, :class:`RadiusNeighborsScalarRegressor -# `. +# :class:`KNeighborsRegressor +# `, and other one based in the +# radius, :class:`RadiusNeighborsRegressor +# `. # # Firstly we will fetch a dataset to show the basic usage. # @@ -76,8 +76,8 @@ # Firstly we will try make a prediction with the default values of the # estimator, using 5 neighbors and the :math:`\mathbb{L}^2` distance. # -# We can fit the :class:`KNeighborsScalarRegressor -# ` in the same way than the +# We can fit the :class:`KNeighborsRegressor +# ` in the same way than the # sklearn estimators. This estimator is an extension of the sklearn # :class:`sklearn.neighbors.KNeighborsRegressor`, but accepting a # :class:`FDataGrid ` as input instead of an array with @@ -85,7 +85,7 @@ # -knn = KNeighborsScalarRegressor(weights='distance') +knn = KNeighborsRegressor(weights='distance') knn.fit(X_train, y_train) ################################################################################ @@ -148,7 +148,7 @@ 'weights': ['uniform', 'distance']} -knn = KNeighborsScalarRegressor(metric='euclidean', sklearn_metric=True) +knn = KNeighborsRegressor(metric='euclidean', multivariate_metric=True) gscv = GridSearchCV(knn, param_grid, cv=KFold(n_splits=3, shuffle=True, random_state=0)) gscv.fit(X, log_prec) diff --git a/examples/plot_radius_neighbors_classification.py b/examples/plot_radius_neighbors_classification.py index a4deadcb1..102e5e296 100644 --- a/examples/plot_radius_neighbors_classification.py +++ b/examples/plot_radius_neighbors_classification.py @@ -159,7 +159,7 @@ # radius_nn = RadiusNeighborsClassifier(radius=3, metric='euclidean', - weights='distance', sklearn_metric=True) + weights='distance', multivariate_metric=True) radius_nn.fit(X_train, y_train) diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 6d42894d4..ac1fb2e3d 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -93,8 +93,8 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, See also -------- RadiusNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor + KNeighborsRegressor + RadiusNeighborsRegressor NearestNeighbors NearestCentroids Notes @@ -249,8 +249,8 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, See also -------- KNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor + KNeighborsRegressor + RadiusNeighborsRegressor NearestNeighbors NearestCentroids @@ -354,9 +354,10 @@ class and return a :class:`FData` object with only one sample -------- KNeighborsClassifier RadiusNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor + KNeighborsRegressor + RadiusNeighborsegressor NearestNeighbors + NearestCentroids """ diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index 9ae093fda..944849e7a 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -79,12 +79,12 @@ class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, >>> neigh = KNeighborsRegressor() >>> neigh.fit(X_train, y_train) - KNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) + KNeighborsRegressor(algorithm='auto', leaf_size=30,...) We can predict the modes of new samples >>> neigh.predict(X_test).round(2) # Predict test data - array([0.38, 0.14, 0.27, 0.52, 0.38]) + array([ 0.38, 0.14, 0.27, 0.52, 0.38]) Now we will create a functional response to train the model @@ -96,7 +96,7 @@ class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, We train the estimator with the functional response >>> neigh.fit(X_train, y_train) - KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + KNeighborsRegressor(algorithm='auto', leaf_size=30,...) And predict the responses as in the first case. @@ -107,7 +107,7 @@ class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, -------- KNeighborsClassifier RadiusNeighborsClassifier - RadiusNeighborsScalarRegressor + RadiusNeighborsRegressor NearestNeighbors NearestCentroids Notes @@ -247,12 +247,12 @@ class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, >>> neigh = RadiusNeighborsRegressor(radius=0.2) >>> neigh.fit(X_train, y_train) - KNeighborsScalarRegressor(algorithm='auto', leaf_size=30,...) + RadiusNeighborsRegressor(algorithm='auto', leaf_size=30,...) We can predict the modes of new samples >>> neigh.predict(X_test).round(2) # Predict test data - array([0.39, 0.07, 0.26, 0.5 , 0.46]) + array([ 0.39, 0.07, 0.26, 0.5 , 0.46]) Now we will create a functional response to train the model @@ -264,7 +264,7 @@ class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, We train the estimator with the functional response >>> neigh.fit(X_train, y_train) - KNeighborsFunctionalRegressor(algorithm='auto', leaf_size=30,...) + RadiusNeighborsRegressor(algorithm='auto', leaf_size=30,...) And predict the responses as in the first case. @@ -275,7 +275,7 @@ class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, -------- KNeighborsClassifier RadiusNeighborsClassifier - KNeighborsScalarRegressor + KNeighborsRegressor NearestNeighbors NearestCentroids Notes diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index 1b87991a3..b6215b5b3 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -84,8 +84,8 @@ class NearestNeighbors(NeighborsBase, NeighborsMixin, KNeighborsMixin, -------- KNeighborsClassifier RadiusNeighborsClassifier - KNeighborsScalarRegressor - RadiusNeighborsScalarRegressor + KNeighborsRegressor + RadiusNeighborsRegressor NearestCentroids Notes ----- From 290d580d00d9f0380976368e7e40a164efcac5e1 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 7 Sep 2019 12:41:12 +0200 Subject: [PATCH 209/222] Coverage and PEP8 --- skfda/_neighbors/base.py | 11 ++++------- skfda/_neighbors/classification.py | 1 - tests/test_neighbors.py | 9 +++++++++ 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 29a482ce7..4c3a85154 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -166,7 +166,8 @@ def fit(self, X, y=None): metric = lp_distance else: metric = self.metric - sk_metric = _to_multivariate_metric(metric, self._sample_points) + sk_metric = _to_multivariate_metric(metric, + self._sample_points) else: sk_metric = self.metric @@ -489,7 +490,8 @@ def _functional_fit(self, X, y): else: metric = self.metric - sk_metric = _to_multivariate_metric(metric, self._sample_points) + sk_metric = _to_multivariate_metric(metric, + self._sample_points) else: sk_metric = self.metric @@ -551,7 +553,6 @@ def predict(self, X): or :class:`FData` containing as many samples as X. """ - self._check_is_fitted() # Choose type of prediction @@ -577,7 +578,6 @@ def _multivariate_predict(self, X): ``sklearn.neighbors``. """ - X = self._transform_to_multivariate(X) return self.estimator_.predict(X) @@ -652,9 +652,7 @@ def _outlier_response(self, neighbors): else: return self.outlier_response - def score(self, X, y, sample_weight=None): - r"""Return the coefficient of determination R^2 of the prediction. In the multivariate response case, the coefficient :math:`R^2` is @@ -699,7 +697,6 @@ def score(self, X, y, sample_weight=None): # Default sklearn multivariate score return super().score(X, y, sample_weight=sample_weight) - def _functional_score(self, X, y, sample_weight=None): r"""Return an extension of the coefficient of determination R^2. diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index ac1fb2e3d..d162fc42a 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -1,7 +1,6 @@ """Neighbor models for supervised classification.""" - from sklearn.utils.multiclass import check_classification_targets from sklearn.preprocessing import LabelEncoder from sklearn.base import ClassifierMixin, BaseEstimator diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index 0fb46ff6c..d4df75fdc 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -282,6 +282,15 @@ def test_search_neighbors_sklearn(self): result = np.array([[0, 3], [1, 2], [2, 1], [3, 0]]) np.testing.assert_array_almost_equal(neighbors, result) + def test_score_scalar_response(self): + + neigh = KNeighborsRegressor() + + neigh.fit(self.X, self.modes_location) + r = neigh.score(self.X, self.modes_location) + np.testing.assert_almost_equal(r, 0.9975889963743335) + + def test_score_functional_response(self): neigh = KNeighborsRegressor() From 87d5c5b81a640795bf790769366b362e7a9a35c5 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 7 Sep 2019 15:59:23 +0200 Subject: [PATCH 210/222] Rename local_regressor as _local_regressor --- skfda/_neighbors/base.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 4c3a85154..1970ac692 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -505,11 +505,11 @@ def _functional_fit(self, X, y): # Choose proper local regressor if self.weights == 'uniform': - self.local_regressor = self._uniform_local_regression + self._local_regressor = self._uniform_local_regression elif self.weights == 'distance': - self.local_regressor = self._distance_local_regression + self._local_regressor = self._distance_local_regression else: - self.local_regressor = self._weighted_local_regression + self._local_regressor = self._weighted_local_regression # Store the responses self._y = y @@ -626,13 +626,14 @@ def _functional_predict(self, X): if len(neighbors[0]) == 0: pred = self._outlier_response(neighbors) else: - pred = self.local_regressor(self._y[neighbors[0]], distances[0]) + pred = self._local_regressor(self._y[neighbors[0]], distances[0]) for i, idx in enumerate(neighbors[1:]): if len(idx) == 0: new_pred = self._outlier_response(neighbors) else: - new_pred = self.local_regressor(self._y[idx], distances[i + 1]) + new_pred = self._local_regressor(self._y[idx], + distances[i + 1]) pred = pred.concatenate(new_pred) From 07083ef44bb9b6ee292b88887275b86063f2b68b Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 7 Sep 2019 17:24:16 +0200 Subject: [PATCH 211/222] Use short sphinx reference, and fix see also links of nearest neighbors --- .../plot_neighbors_functional_regression.py | 6 ++-- skfda/_neighbors/classification.py | 32 +++++++++---------- skfda/_neighbors/regression.py | 22 +++++++------ skfda/_neighbors/unsupervised.py | 11 ++++--- 4 files changed, 36 insertions(+), 35 deletions(-) diff --git a/examples/plot_neighbors_functional_regression.py b/examples/plot_neighbors_functional_regression.py index fee1a162f..f3524c2c8 100644 --- a/examples/plot_neighbors_functional_regression.py +++ b/examples/plot_neighbors_functional_regression.py @@ -22,10 +22,8 @@ # # In this example we are going to show the usage of the nearest neighbors # regressors with functional response. There is available a K-nn version, -# :class:`KNeighborsRegressor -# `, and other one based in -# the radius, :class:`RadiusNeighborsRegressor -# `. +# :class:`~skfda.ml.regression.KNeighborsRegressor`, and other one based in +# the radius, :class:`~skfda.ml.regression.RadiusNeighborsRegressor`. # # # As in the :ref:`scalar response example diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index d162fc42a..2a418ab81 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -91,11 +91,12 @@ class KNeighborsClassifier(NeighborsBase, NeighborsMixin, KNeighborsMixin, See also -------- - RadiusNeighborsClassifier - KNeighborsRegressor - RadiusNeighborsRegressor - NearestNeighbors - NearestCentroids + :class:`~skfda.ml.classification.RadiusNeighborsClassifier` + :class:`~skfda.ml.classification.NearestCentroids` + :class:`~skfda.ml.regression.KNeighborsRegressor` + :class:`~skfda.ml.regression.RadiusNeighborsRegressor` + :class:`~skfda.ml.clustering.NearestNeighbors` + Notes ----- See Nearest Neighbors in the sklearn online documentation for a discussion @@ -247,11 +248,11 @@ class RadiusNeighborsClassifier(NeighborsBase, NeighborsMixin, See also -------- - KNeighborsClassifier - KNeighborsRegressor - RadiusNeighborsRegressor - NearestNeighbors - NearestCentroids + :class:`~skfda.ml.classification.KNeighborsClassifier` + :class:`~skfda.ml.classification.NearestCentroids` + :class:`~skfda.ml.regression.KNeighborsRegressor` + :class:`~skfda.ml.regression.RadiusNeighborsRegressor` + :class:`~skfda.ml.clustering.NearestNeighbors` Notes ----- @@ -351,12 +352,11 @@ class and return a :class:`FData` object with only one sample See also -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsRegressor - RadiusNeighborsegressor - NearestNeighbors - NearestCentroids + :class:`~skfda.ml.classification.KNeighborsClassifier` + :class:`~skfda.ml.classification.RadiusNeighborsClassifier` + :class:`~skfda.ml.regression.KNeighborsRegressor` + :class:`~skfda.ml.regression.RadiusNeighborsRegressor` + :class:`~skfda.ml.clustering.NearestNeighbors` """ diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index 944849e7a..a7a1451f4 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -105,11 +105,12 @@ class KNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, See also -------- - KNeighborsClassifier - RadiusNeighborsClassifier - RadiusNeighborsRegressor - NearestNeighbors - NearestCentroids + :class:`~skfda.ml.classification.KNeighborsClassifier` + :class:`~skfda.ml.classification.RadiusNeighborsClassifier` + :class:`~skfda.ml.classification.NearestCentroids` + :class:`~skfda.ml.regression.RadiusNeighborsRegressor` + :class:`~skfda.ml.clustering.NearestNeighbors` + Notes ----- See Nearest Neighbors in the sklearn online documentation for a discussion @@ -273,11 +274,12 @@ class RadiusNeighborsRegressor(NeighborsBase, NeighborsRegressorMixin, See also -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsRegressor - NearestNeighbors - NearestCentroids + :class:`~skfda.ml.classification.KNeighborsClassifier` + :class:`~skfda.ml.classification.RadiusNeighborsClassifier` + :class:`~skfda.ml.classification.NearestCentroids` + :class:`~skfda.ml.regression.KNeighborsRegressor` + :class:`~skfda.ml.clustering.NearestNeighbors` + Notes ----- See Nearest Neighbors in the sklearn online documentation for a discussion diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index b6215b5b3..b552a8cbd 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -82,11 +82,12 @@ class NearestNeighbors(NeighborsBase, NeighborsMixin, KNeighborsMixin, See also -------- - KNeighborsClassifier - RadiusNeighborsClassifier - KNeighborsRegressor - RadiusNeighborsRegressor - NearestCentroids + :class:`~skfda.ml.classification.KNeighborsClassifier` + :class:`~skfda.ml.classification.RadiusNeighborsClassifier` + :class:`~skfda.ml.classification.NearestCentroids` + :class:`~skfda.ml.regression.KNeighborsRegressor` + :class:`~skfda.ml.regression.RadiusNeighborsRegressor` + Notes ----- See Nearest Neighbors in the sklearn online documentation for a discussion From c1fbcc35710f42f6a2b3bbcbaa19f9f342b88393 Mon Sep 17 00:00:00 2001 From: pablomm Date: Sat, 7 Sep 2019 19:01:02 +0200 Subject: [PATCH 212/222] Rename sk_metric to sklearn_metric --- skfda/_neighbors/base.py | 24 ++++++++++++------------ skfda/_neighbors/classification.py | 12 ++++++------ skfda/_neighbors/regression.py | 12 ++++++------ skfda/_neighbors/unsupervised.py | 6 +++--- 4 files changed, 27 insertions(+), 27 deletions(-) diff --git a/skfda/_neighbors/base.py b/skfda/_neighbors/base.py index 1970ac692..5e73364cd 100644 --- a/skfda/_neighbors/base.py +++ b/skfda/_neighbors/base.py @@ -166,12 +166,12 @@ def fit(self, X, y=None): metric = lp_distance else: metric = self.metric - sk_metric = _to_multivariate_metric(metric, - self._sample_points) + sklearn_metric = _to_multivariate_metric(metric, + self._sample_points) else: - sk_metric = self.metric + sklearn_metric = self.metric - self.estimator_ = self._init_estimator(sk_metric) + self.estimator_ = self._init_estimator(sklearn_metric) self.estimator_.fit(self._transform_to_multivariate(X), y) return self @@ -490,12 +490,12 @@ def _functional_fit(self, X, y): else: metric = self.metric - sk_metric = _to_multivariate_metric(metric, - self._sample_points) + sklearn_metric = _to_multivariate_metric(metric, + self._sample_points) else: - sk_metric = self.metric + sklearn_metric = self.metric - self.estimator_ = self._init_estimator(sk_metric) + self.estimator_ = self._init_estimator(sklearn_metric) self.estimator_.fit(self._transform_to_multivariate(X)) if self.regressor == 'mean': @@ -582,11 +582,11 @@ def _multivariate_predict(self, X): return self.estimator_.predict(X) - def _init_estimator(self, sk_metric): + def _init_estimator(self, sklearn_metric): """Initialize the sklearn nearest neighbors estimator. Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn_metric: (pyfunc or 'precomputed'): Metric compatible with sklearn API or matrix (n_samples, n_samples) with precomputed distances. @@ -600,10 +600,10 @@ def _init_estimator(self, sk_metric): return _NearestNeighbors( n_neighbors=self.n_neighbors, radius=self.radius, algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, + metric=sklearn_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) else: - return self._init_multivariate_estimator(sk_metric) + return self._init_multivariate_estimator(sklearn_metric) def _functional_predict(self, X): """Predict functional responses. diff --git a/skfda/_neighbors/classification.py b/skfda/_neighbors/classification.py index 2a418ab81..c8f63482d 100644 --- a/skfda/_neighbors/classification.py +++ b/skfda/_neighbors/classification.py @@ -126,11 +126,11 @@ def __init__(self, n_neighbors=5, weights='uniform', algorithm='auto', metric_params=metric_params, n_jobs=n_jobs, multivariate_metric=multivariate_metric) - def _init_estimator(self, sk_metric): + def _init_estimator(self, sklearn_metric): """Initialize the sklearn K neighbors estimator. Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn_metric: (pyfunc or 'precomputed'): Metric compatible with sklearn API or matrix (n_samples, n_samples) with precomputed distances. @@ -144,7 +144,7 @@ def _init_estimator(self, sk_metric): return _KNeighborsClassifier( n_neighbors=self.n_neighbors, weights=self.weights, algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, + metric=sklearn_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) def predict_proba(self, X): @@ -278,11 +278,11 @@ def __init__(self, radius=1.0, weights='uniform', algorithm='auto', self.outlier_label = outlier_label - def _init_estimator(self, sk_metric): + def _init_estimator(self, sklearn_metric): """Initialize the sklearn radius neighbors estimator. Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn_metric: (pyfunc or 'precomputed'): Metric compatible with sklearn API or matrix (n_samples, n_samples) with precomputed distances. @@ -296,7 +296,7 @@ def _init_estimator(self, sk_metric): return _RadiusNeighborsClassifier( radius=self.radius, weights=self.weights, algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, + metric=sklearn_metric, metric_params=self.metric_params, outlier_label=self.outlier_label, n_jobs=self.n_jobs) diff --git a/skfda/_neighbors/regression.py b/skfda/_neighbors/regression.py index a7a1451f4..8300215ee 100644 --- a/skfda/_neighbors/regression.py +++ b/skfda/_neighbors/regression.py @@ -141,11 +141,11 @@ def __init__(self, n_neighbors=5, weights='uniform', regressor='mean', multivariate_metric=multivariate_metric) self.regressor = regressor - def _init_multivariate_estimator(self, sk_metric): + def _init_multivariate_estimator(self, sklearn_metric): """Initialize the sklearn K neighbors estimator. Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn_metric: (pyfunc or 'precomputed'): Metric compatible with sklearn API or matrix (n_samples, n_samples) with precomputed distances. @@ -159,7 +159,7 @@ def _init_multivariate_estimator(self, sk_metric): return _KNeighborsRegressor( n_neighbors=self.n_neighbors, weights=self.weights, algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, + metric=sklearn_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) def _query(self, X): @@ -305,11 +305,11 @@ def __init__(self, radius=1.0, weights='uniform', regressor='mean', self.regressor = regressor self.outlier_response = outlier_response - def _init_multivariate_estimator(self, sk_metric): + def _init_multivariate_estimator(self, sklearn_metric): """Initialize the sklearn radius neighbors estimator. Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn_metric: (pyfunc or 'precomputed'): Metric compatible with sklearn API or matrix (n_samples, n_samples) with precomputed distances. @@ -323,7 +323,7 @@ def _init_multivariate_estimator(self, sk_metric): return _RadiusNeighborsRegressor( radius=self.radius, weights=self.weights, algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, + metric=sklearn_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) def _query(self, X): diff --git a/skfda/_neighbors/unsupervised.py b/skfda/_neighbors/unsupervised.py index b552a8cbd..9e2fbee1a 100644 --- a/skfda/_neighbors/unsupervised.py +++ b/skfda/_neighbors/unsupervised.py @@ -111,11 +111,11 @@ def __init__(self, n_neighbors=5, radius=1.0, algorithm='auto', n_jobs=n_jobs, multivariate_metric=multivariate_metric) - def _init_estimator(self, sk_metric): + def _init_estimator(self, sklearn_metric): """Initialize the sklearn nearest neighbors estimator. Args: - sk_metric: (pyfunc or 'precomputed'): Metric compatible with + sklearn_metric: (pyfunc or 'precomputed'): Metric compatible with sklearn API or matrix (n_samples, n_samples) with precomputed distances. @@ -128,5 +128,5 @@ def _init_estimator(self, sk_metric): return _NearestNeighbors( n_neighbors=self.n_neighbors, radius=self.radius, algorithm=self.algorithm, leaf_size=self.leaf_size, - metric=sk_metric, metric_params=self.metric_params, + metric=sklearn_metric, metric_params=self.metric_params, n_jobs=self.n_jobs) From 3db044f0bb08d41d5f265f282a1cdf86d57b5f2b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 9 Sep 2019 15:59:19 +0200 Subject: [PATCH 213/222] Rename nbasis to n_basis. The name was changed to keep a uniform nomenclature. --- examples/plot_extrapolation.py | 4 +- .../plot_neighbors_functional_regression.py | 2 +- examples/plot_representation.py | 6 +- examples/plot_shift_registration_basis.py | 2 +- skfda/_utils/constants.py | 11 +- skfda/ml/regression/linear_model.py | 5 +- .../registration/_landmark_registration.py | 2 +- .../registration/_registration_utils.py | 2 +- .../registration/_shift_registration.py | 2 +- skfda/preprocessing/smoothing/_basis.py | 18 +- skfda/representation/basis.py | 218 +++++++++--------- skfda/representation/grid.py | 2 +- tests/test_basis.py | 180 +++++++-------- tests/test_basis_evaluation.py | 75 +++--- tests/test_extrapolation.py | 7 +- tests/test_lfd.py | 5 +- tests/test_metrics.py | 2 +- tests/test_neighbors.py | 17 +- tests/test_pandas.py | 2 +- tests/test_regression.py | 49 ++-- tests/test_smoothing.py | 6 +- 21 files changed, 309 insertions(+), 308 deletions(-) diff --git a/examples/plot_extrapolation.py b/examples/plot_extrapolation.py index 4b508f353..1bde81622 100644 --- a/examples/plot_extrapolation.py +++ b/examples/plot_extrapolation.py @@ -46,10 +46,10 @@ fd_fourier = fdgrid.to_basis(skfda.representation.basis.Fourier()) fd_fourier.dataset_label = "Fourier Basis" -fd_monomial = fdgrid.to_basis(skfda.representation.basis.Monomial(nbasis=5)) +fd_monomial = fdgrid.to_basis(skfda.representation.basis.Monomial(n_basis=5)) fd_monomial.dataset_label = "Monomial Basis" -fd_bspline = fdgrid.to_basis(skfda.representation.basis.BSpline(nbasis=5)) +fd_bspline = fdgrid.to_basis(skfda.representation.basis.BSpline(n_basis=5)) fd_bspline.dataset_label = "BSpline Basis" diff --git a/examples/plot_neighbors_functional_regression.py b/examples/plot_neighbors_functional_regression.py index f3524c2c8..44acf8fd1 100644 --- a/examples/plot_neighbors_functional_regression.py +++ b/examples/plot_neighbors_functional_regression.py @@ -57,7 +57,7 @@ # representation, employing for it a fourier basis with 5 elements. # -y = y.to_basis(Fourier(nbasis=5)) +y = y.to_basis(Fourier(n_basis=5)) y.plot() diff --git a/examples/plot_representation.py b/examples/plot_representation.py index 2f99691a6..1aa2de55f 100644 --- a/examples/plot_representation.py +++ b/examples/plot_representation.py @@ -80,14 +80,14 @@ ############################################################################## # We will represent it using a basis of B-splines. -fd_basis = fd.to_basis(basis.BSpline(nbasis=4)) +fd_basis = fd.to_basis(basis.BSpline(n_basis=4)) fd_basis.plot() ############################################################################## # We can increase the number of elements in the basis to try to reproduce the # original data with more fidelity. -fd_basis_big = fd.to_basis(basis.BSpline(nbasis=7)) +fd_basis_big = fd.to_basis(basis.BSpline(n_basis=7)) fd_basis_big.plot() @@ -105,7 +105,7 @@ # For example, in the Fourier basis the functions start and end at the same # points if the period is equal to the domain range, so this basis is clearly # non suitable for the Growth dataset. -fd_basis = fd.to_basis(basis.Fourier(nbasis=7)) +fd_basis = fd.to_basis(basis.Fourier(n_basis=7)) fd_basis.plot() diff --git a/examples/plot_shift_registration_basis.py b/examples/plot_shift_registration_basis.py index 6b7a39505..79dd8bdff 100644 --- a/examples/plot_shift_registration_basis.py +++ b/examples/plot_shift_registration_basis.py @@ -35,7 +35,7 @@ # # Because of their sinusoidal nature we will use a Fourier basis. -basis = skfda.representation.basis.Fourier(nbasis=11) +basis = skfda.representation.basis.Fourier(n_basis=11) fd_basis = fd.to_basis(basis) fd_basis.plot() diff --git a/skfda/_utils/constants.py b/skfda/_utils/constants.py index ecd96756c..634049f3a 100644 --- a/skfda/_utils/constants.py +++ b/skfda/_utils/constants.py @@ -3,18 +3,19 @@ The following constants are defined: .. data:: BASIS_MIN_FACTOR Constant used in the discretization of a basis object, by default de - number of points used are the maximum between BASIS_MIN_FACTOR * nbasis +1 - and N_POINTS_FINE_MESH. + number of points used are the maximum between + BASIS_MIN_FACTOR * n_basis + 1 and N_POINTS_FINE_MESH. .. data:: N_POINTS_FINE_MESH Constant used in the discretization of a basis object, by default de - number of points used are the maximum between BASIS_MIN_FACTOR * nbasis +1 - and N_POINTS_FINE_MESH. + number of points used are the maximum between + BASIS_MIN_FACTOR * n_basis + 1 and N_POINTS_FINE_MESH. .. data:: N_POINTS_COARSE_MESH Constant used in the default discretization of a basis in some methods. .. data:: N_POINTS_UNIDIMENSIONAL_PLOT_MESH Number of points used in the evaluation of a function to be plotted. .. data:: N_POINTS_SURFACE_PLOT_AX - Number of points per axis used in the evaluation of a surface to be plotted. + Number of points per axis used in the evaluation of a surface to be + plotted. """ BASIS_MIN_FACTOR = 10 diff --git a/skfda/ml/regression/linear_model.py b/skfda/ml/regression/linear_model.py index 21a40f47e..49014b114 100644 --- a/skfda/ml/regression/linear_model.py +++ b/skfda/ml/regression/linear_model.py @@ -41,8 +41,9 @@ def fit(self, X, y=None, sample_weight=None): idx = 0 for j in range(0, nbeta): self.beta_basis[j] = FDataBasis( - self.beta_basis[j], betacoefs[idx:idx + self.beta_basis[j].nbasis].T) - idx = idx + self.beta_basis[j].nbasis + self.beta_basis[j], + betacoefs[idx:idx + self.beta_basis[j].n_basis].T) + idx = idx + self.beta_basis[j].n_basis self.beta_ = self.beta_basis return self diff --git a/skfda/preprocessing/registration/_landmark_registration.py b/skfda/preprocessing/registration/_landmark_registration.py index 4582a9b2a..2036569fa 100644 --- a/skfda/preprocessing/registration/_landmark_registration.py +++ b/skfda/preprocessing/registration/_landmark_registration.py @@ -319,7 +319,7 @@ def landmark_registration(fd, landmarks, *, location=None, eval_points=None): This method will work for FDataBasis as for FDataGrids - >>> fd = fd.to_basis(BSpline(nbasis=12)) + >>> fd = fd.to_basis(BSpline(n_basis=12)) >>> landmark_registration(fd, landmarks) FDataBasis(...) diff --git a/skfda/preprocessing/registration/_registration_utils.py b/skfda/preprocessing/registration/_registration_utils.py index 36e129c71..e8735c584 100644 --- a/skfda/preprocessing/registration/_registration_utils.py +++ b/skfda/preprocessing/registration/_registration_utils.py @@ -153,7 +153,7 @@ def mse_decomposition(original_fdata, registered_fdata, warping_function=None, eval_points = registered_fdata.sample_points[0] except AttributeError: - nfine = max(registered_fdata.basis.nbasis * 10 + 1, 201) + nfine = max(registered_fdata.basis.n_basis * 10 + 1, 201) domain_range = registered_fdata.domain_range[0] eval_points = np.linspace(*domain_range, nfine) else: diff --git a/skfda/preprocessing/registration/_shift_registration.py b/skfda/preprocessing/registration/_shift_registration.py index 12bb6b5bb..491188654 100644 --- a/skfda/preprocessing/registration/_shift_registration.py +++ b/skfda/preprocessing/registration/_shift_registration.py @@ -122,7 +122,7 @@ def shift_registration_deltas(fd, *, maxiter=5, tol=1e-2, eval_points = fd.sample_points[0] nfine = len(eval_points) except AttributeError: - nfine = max(fd.nbasis * constants.BASIS_MIN_FACTOR + 1, + nfine = max(fd.n_basis * constants.BASIS_MIN_FACTOR + 1, constants.N_POINTS_COARSE_MESH) eval_points = np.linspace(*domain_range, nfine) diff --git a/skfda/preprocessing/smoothing/_basis.py b/skfda/preprocessing/smoothing/_basis.py index 292183a04..f86623de7 100644 --- a/skfda/preprocessing/smoothing/_basis.py +++ b/skfda/preprocessing/smoothing/_basis.py @@ -210,7 +210,7 @@ class BasisSmoother(_LinearSmoother): array([ 1., 1., -1., -1., 1.]) >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) - >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( ... basis, method='cholesky') >>> fd_smooth = smoother.fit_transform(fd) @@ -225,7 +225,7 @@ class BasisSmoother(_LinearSmoother): in basis form, by default, without extra smoothing: >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) - >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( ... basis, method='cholesky', return_basis=True) >>> fd_basis = smoother.fit_transform(fd) @@ -256,7 +256,7 @@ class BasisSmoother(_LinearSmoother): >>> from skfda.misc import LinearDifferentialOperator >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) - >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( ... basis, method='cholesky', ... smoothing_parameter=1, @@ -268,7 +268,7 @@ class BasisSmoother(_LinearSmoother): >>> from skfda.misc import LinearDifferentialOperator >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) - >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( ... basis, method='qr', ... smoothing_parameter=1, @@ -280,7 +280,7 @@ class BasisSmoother(_LinearSmoother): >>> from skfda.misc import LinearDifferentialOperator >>> fd = skfda.FDataGrid(data_matrix=x, sample_points=t) - >>> basis = skfda.representation.basis.Fourier((0, 1), nbasis=3) + >>> basis = skfda.representation.basis.Fourier((0, 1), n_basis=3) >>> smoother = skfda.preprocessing.smoothing.BasisSmoother( ... basis, method='matrix', ... smoothing_parameter=1, @@ -467,7 +467,7 @@ def fit_transform(self, X: FDataGrid, y=None): # C the coefficient matrix (the unknown) # Y is the data_matrix - if(data_matrix.shape[0] > self.basis.nbasis + if(data_matrix.shape[0] > self.basis.n_basis or self.smoothing_parameter > 0): # TODO: The penalty could be None (if the matrix is passed) @@ -488,14 +488,14 @@ def fit_transform(self, X: FDataGrid, y=None): penalty_matrix=penalty_matrix, ndegenerated=ndegenerated) - elif data_matrix.shape[0] == self.basis.nbasis: + elif data_matrix.shape[0] == self.basis.n_basis: # If the number of basis equals the number of points and no # smoothing is required coefficients = np.linalg.solve(basis_values, data_matrix).T - else: # data_matrix.shape[0] < basis.nbasis + else: # data_matrix.shape[0] < basis.n_basis raise ValueError(f"The number of basis functions " - f"({self.basis.nbasis}) " + f"({self.basis.n_basis}) " f"exceed the number of points to be smoothed " f"({data_matrix.shape[0]}).") diff --git a/skfda/representation/basis.py b/skfda/representation/basis.py index e9fec1847..9709ae469 100644 --- a/skfda/representation/basis.py +++ b/skfda/representation/basis.py @@ -58,17 +58,17 @@ class Basis(ABC): Attributes: domain_range (tuple): a tuple of length 2 containing the initial and end values of the interval over which the basis can be evaluated. - nbasis (int): number of functions in the basis. + n_basis (int): number of functions in the basis. """ - def __init__(self, domain_range=None, nbasis=1): + def __init__(self, domain_range=None, n_basis=1): """Basis constructor. Args: domain_range (tuple or list of tuples, optional): Definition of the interval where the basis defines a space. Defaults to (0,1). - nbasis: Number of functions that form the basis. Defaults to 1. + n_basis: Number of functions that form the basis. Defaults to 1. """ if domain_range is not None: @@ -78,12 +78,12 @@ def __init__(self, domain_range=None, nbasis=1): # Some checks _check_domain(domain_range) - if nbasis < 1: + if n_basis < 1: raise ValueError("The number of basis has to be strictly " "possitive.") self._domain_range = domain_range - self.nbasis = nbasis + self.n_basis = n_basis self._drop_index_lst = [] super().__init__() @@ -203,7 +203,7 @@ def _evaluate_single_basis_coefficients(self, coefficients, basis_index, x, """ if x not in cache: - res = np.zeros(self.nbasis) + res = np.zeros(self.n_basis) for i, k in enumerate(coefficients): if callable(k): res += k(x) * self._compute_matrix([x], i)[:, 0] @@ -227,15 +227,15 @@ def _numerical_penalty(self, coefficients): # Range of first dimension domain_range = self.domain_range[0] - penalty_matrix = np.zeros((self.nbasis, self.nbasis)) + penalty_matrix = np.zeros((self.n_basis, self.n_basis)) cache = {} - for i in range(self.nbasis): + for i in range(self.n_basis): penalty_matrix[i, i] = scipy.integrate.quad( lambda x: (self._evaluate_single_basis_coefficients( coefficients, i, x, cache) ** 2), domain_range[0], domain_range[1] )[0] - for j in range(i + 1, self.nbasis): + for j in range(i + 1, self.n_basis): penalty_matrix[i, j] = scipy.integrate.quad( (lambda x: (self._evaluate_single_basis_coefficients( coefficients, i, x, cache) * @@ -296,9 +296,9 @@ def default_basis_of_product(one, other): if not _same_domain(one.domain_range, other.domain_range): raise ValueError("Ranges are not equal.") - norder = min(8, one.nbasis + other.nbasis) - nbasis = max(one.nbasis + other.nbasis, norder + 1) - return BSpline(one.domain_range, nbasis, norder) + norder = min(8, one.n_basis + other.n_basis) + n_basis = max(one.n_basis + other.n_basis, norder + 1) + return BSpline(one.domain_range, n_basis, norder) def rescale(self, domain_range=None): r"""Return a copy of the basis with a new domain range, with the @@ -313,7 +313,7 @@ def rescale(self, domain_range=None): if domain_range is None: domain_range = self.domain_range - return type(self)(domain_range, self.nbasis) + return type(self)(domain_range, self.n_basis) def same_domain(self, other): r"""Returns if two basis are defined on the same domain range. @@ -328,7 +328,7 @@ def copy(self): return copy.deepcopy(self) def to_basis(self): - return FDataBasis(self.copy(), np.identity(self.nbasis)) + return FDataBasis(self.copy(), np.identity(self.n_basis)) def _list_to_R(self, knots): retstring = "c(" @@ -367,10 +367,10 @@ def _inner_matrix(self, other=None): first = self.to_basis() second = other.to_basis() - inner = np.zeros((self.nbasis, other.nbasis)) + inner = np.zeros((self.n_basis, other.n_basis)) - for i in range(self.nbasis): - for j in range(other.nbasis): + for i in range(self.n_basis): + for j in range(other.n_basis): inner[i, j] = first[i].inner_product(second[j], None, None) return inner @@ -392,10 +392,10 @@ def gram_matrix(self): """ fbasis = self.to_basis() - gram = np.zeros((self.nbasis, self.nbasis)) + gram = np.zeros((self.n_basis, self.n_basis)) - for i in range(fbasis.nbasis): - for j in range(i, fbasis.nbasis): + for i in range(fbasis.n_basis): + for j in range(i, fbasis.n_basis): gram[i, j] = fbasis[i].inner_product(fbasis[j], None, None) gram[j, i] = gram[i, j] @@ -434,13 +434,13 @@ def _mul_constant(self, coefs, other): def __repr__(self): """Representation of a Basis object.""" return (f"{self.__class__.__name__}(domain_range={self.domain_range}, " - f"nbasis={self.nbasis})") + f"n_basis={self.n_basis})") def __eq__(self, other): """Equality of Basis""" return (type(self) == type(other) and _same_domain(self.domain_range, other.domain_range) - and self.nbasis == other.nbasis) + and self.n_basis == other.n_basis) class Constant(Basis): @@ -582,13 +582,13 @@ class Monomial(Basis): Attributes: domain_range (tuple): a tuple of length 2 containing the initial and end values of the interval over which the basis can be evaluated. - nbasis (int): number of functions in the basis. + n_basis (int): number of functions in the basis. Examples: Defines a monomial base over the interval :math:`[0, 5]` consisting on the first 3 powers of :math:`t`: :math:`1, t, t^2`. - >>> bs_mon = Monomial((0,5), nbasis=3) + >>> bs_mon = Monomial((0,5), n_basis=3) And evaluates all the functions in the basis in a list of descrete values. @@ -642,14 +642,14 @@ def _compute_matrix(self, eval_points, derivative=0): """ # Initialise empty matrix - mat = np.zeros((self.nbasis, len(eval_points))) + mat = np.zeros((self.n_basis, len(eval_points))) # For each basis computes its value for each evaluation if derivative == 0: - for i in range(self.nbasis): + for i in range(self.n_basis): mat[i] = eval_points ** i else: - for i in range(self.nbasis): + for i in range(self.n_basis): if derivative <= i: factor = i for j in range(2, derivative + 1): @@ -659,7 +659,7 @@ def _compute_matrix(self, eval_points, derivative=0): return mat def _derivative(self, coefs, order=1): - return (Monomial(self.domain_range, self.nbasis - order), + return (Monomial(self.domain_range, self.n_basis - order), np.array([np.polyder(x[::-1], order)[::-1] for x in coefs])) @@ -692,7 +692,7 @@ def penalty(self, derivative_degree=None, coefficients=None): numpy.array: Penalty matrix. Examples: - >>> Monomial(nbasis=4).penalty(2) + >>> Monomial(n_basis=4).penalty(2) array([[ 0., 0., 0., 0.], [ 0., 0., 0., 0.], [ 0., 0., 4., 6.], @@ -711,9 +711,9 @@ def penalty(self, derivative_degree=None, coefficients=None): integration_domain = self.domain_range[0] # initialize penalty matrix as all zeros - penalty_matrix = np.zeros((self.nbasis, self.nbasis)) + penalty_matrix = np.zeros((self.n_basis, self.n_basis)) # iterate over the cartesion product of the basis system with itself - for ibasis in range(self.nbasis): + for ibasis in range(self.n_basis): # notice that the index ibasis it is also the exponent of the # monomial # ifac is the factor resulting of deriving the monomial as many @@ -725,7 +725,7 @@ def penalty(self, derivative_degree=None, coefficients=None): else: ifac = 1 - for jbasis in range(self.nbasis): + for jbasis in range(self.n_basis): # notice that the index jbasis it is also the exponent of the # monomial # jfac is the factor resulting of deriving the monomial as @@ -762,7 +762,7 @@ def basis_of_product(self, other): raise ValueError("Ranges are not equal.") if isinstance(other, Monomial): - return Monomial(self.domain_range, self.nbasis + other.nbasis) + return Monomial(self.domain_range, self.n_basis + other.n_basis) return other.rbasis_of_product(self) @@ -773,7 +773,7 @@ def rbasis_of_product(self, other): def _to_R(self): drange = self.domain_range[0] return "create.monomial.basis(rangeval = c(" + str(drange[0]) + "," +\ - str(drange[1]) + "), nbasis = " + str(self.nbasis) + ")" + str(drange[1]) + "), n_basis = " + str(self.n_basis) + ")" class BSpline(Basis): @@ -799,19 +799,19 @@ class BSpline(Basis): Attributes: domain_range (tuple): A tuple of length 2 containing the initial and end values of the interval over which the basis can be evaluated. - nbasis (int): Number of functions in the basis. + n_basis (int): Number of functions in the basis. order (int): Order of the splines. One greather than their degree. knots (list): List of knots of the spline functions. Examples: Constructs specifying number of basis and order. - >>> bss = BSpline(nbasis=8, order=4) + >>> bss = BSpline(n_basis=8, order=4) If no order is specified defaults to 4 because cubic splines are the most used. So the previous example is the same as: - >>> bss = BSpline(nbasis=8) + >>> bss = BSpline(n_basis=8) It is also possible to create a BSpline basis specifying the knots. @@ -820,7 +820,7 @@ class BSpline(Basis): Once we create a basis we can evaluate each of its functions at a set of points. - >>> bss = BSpline(nbasis=3, order=3) + >>> bss = BSpline(n_basis=3, order=3) >>> bss.evaluate([0, 0.5, 1]) array([[ 1. , 0.25, 0. ], [ 0. , 0.5 , 0. ], @@ -839,7 +839,7 @@ class BSpline(Basis): """ - def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): + def __init__(self, domain_range=None, n_basis=None, order=4, knots=None): """Bspline basis constructor. Args: @@ -847,7 +847,7 @@ def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): the basis defines a space. Defaults to (0,1) if knots are not specified. If knots are specified defaults to the first and last element of the knots. - nbasis (int, optional): Number of splines that form the basis. + n_basis (int, optional): Number of splines that form the basis. order (int, optional): Order of the splines. One greater that their degree. Defaults to 4 which mean cubic splines. knots (array_like): List of knots of the splines. If domain_range @@ -866,7 +866,7 @@ def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): # Knots default to equally space points in the domain_range if knots is None: - if nbasis is None: + if n_basis is None: raise ValueError("Must provide either a list of knots or the" "number of basis.") else: @@ -879,22 +879,22 @@ def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): raise ValueError("The ends of the knots must be the same " "as the domain_range.") - # nbasis default to number of knots + order of the splines - 2 - if nbasis is None: - nbasis = len(knots) + order - 2 + # n_basis default to number of knots + order of the splines - 2 + if n_basis is None: + n_basis = len(knots) + order - 2 - if (nbasis - order + 2) < 2: - raise ValueError(f"The number of basis ({nbasis}) minus the order " + if (n_basis - order + 2) < 2: + raise ValueError(f"The number of basis ({n_basis}) minus the order " f"of the bspline ({order}) should be greater " f"than 3.") self.order = order self.knots = None if knots is None else list(knots) - super().__init__(domain_range, nbasis) + super().__init__(domain_range, n_basis) # Checks - if self.nbasis != self.order + len(self.knots) - 2: - raise ValueError(f"The number of basis ({self.nbasis}) has to " + if self.n_basis != self.order + len(self.knots) - 2: + raise ValueError(f"The number of basis ({self.n_basis}) has to " f"equal the order ({self.order}) plus the " f"number of knots ({len(self.knots)}) minus 2.") @@ -902,7 +902,7 @@ def __init__(self, domain_range=None, nbasis=None, order=4, knots=None): def knots(self): if self._knots is None: return list(np.linspace(*self.domain_range[0], - self.nbasis - self.order + 2)) + self.n_basis - self.order + 2)) else: return self._knots @@ -956,10 +956,10 @@ def _compute_matrix(self, eval_points, derivative=0): c = np.zeros(len(knots)) # Initialise empty matrix - mat = np.empty((self.nbasis, len(eval_points))) + mat = np.empty((self.n_basis, len(eval_points))) # For each basis computes its value for each evaluation point - for i in range(self.nbasis): + for i in range(self.n_basis): # write a 1 in c in the position of the spline calculated in each # iteration c[i] = 1 @@ -980,7 +980,7 @@ def _derivative(self, coefs, order=1): deriv_basis = BSpline._from_scipy_BSpline(deriv_splines[0])[0] - return deriv_basis, np.array(deriv_coefs)[:, 0:deriv_basis.nbasis] + return deriv_basis, np.array(deriv_coefs)[:, 0:deriv_basis.n_basis] def penalty(self, derivative_degree=None, coefficients=None): r"""Return a penalty matrix given a differential operator. @@ -1049,7 +1049,7 @@ def penalty(self, derivative_degree=None, coefficients=None): no_0_intervals = np.where(np.diff(knots) > 0)[0] # For each basis gets its piecewise polynomial representation - for i in range(self.nbasis): + for i in range(self.n_basis): # write a 1 in c in the position of the spline # transformed in each iteration c[i] = 1 @@ -1076,9 +1076,9 @@ def penalty(self, derivative_degree=None, coefficients=None): # Now for each pair of basis computes the inner product after # applying the linear differential operator - penalty_matrix = np.zeros((self.nbasis, self.nbasis)) + penalty_matrix = np.zeros((self.n_basis, self.n_basis)) for interval in range(len(no_0_intervals)): - for i in range(self.nbasis): + for i in range(self.n_basis): poly_i = np.trim_zeros(ppoly_lst[i][:, interval], 'f') if len(poly_i) <= derivative_degree: @@ -1093,7 +1093,7 @@ def penalty(self, derivative_degree=None, coefficients=None): penalty_matrix[i, i] += np.diff(polyval( integral, self.knots[interval: interval + 2]))[0] - for j in range(i + 1, self.nbasis): + for j in range(i + 1, self.n_basis): poly_j = np.trim_zeros(ppoly_lst[j][:, interval], 'f') if len(poly_j) <= derivative_degree: @@ -1149,12 +1149,12 @@ def rescale(self, domain_range=None): # TODO: Allow multiple dimensions domain_range = self.domain_range[0] - return BSpline(domain_range, self.nbasis, self.order, knots) + return BSpline(domain_range, self.n_basis, self.order, knots) def __repr__(self): """Representation of a BSpline basis.""" return (f"{self.__class__.__name__}(domain_range={self.domain_range}, " - f"nbasis={self.nbasis}, order={self.order}, " + f"n_basis={self.n_basis}, order={self.order}, " f"knots={self.knots})") def __eq__(self, other): @@ -1187,31 +1187,31 @@ def basis_of_product(self, other): m2 = m2 + multunique[i] allknots[m1:m2] = uniqueknots[i] - norder1 = self.nbasis - len(self.inknots) - norder2 = other.nbasis - len(other.inknots) + norder1 = self.n_basis - len(self.inknots) + norder2 = other.n_basis - len(other.inknots) norder = min(norder1 + norder2 - 1, 20) allbreaks = ([self.domain_range[0][0]] + np.ndarray.tolist(allknots) + [self.domain_range[0][1]]) - nbasis = len(allbreaks) + norder - 2 - return BSpline(self.domain_range, nbasis, norder, allbreaks) + n_basis = len(allbreaks) + norder - 2 + return BSpline(self.domain_range, n_basis, norder, allbreaks) else: - norder = min(self.nbasis - len(self.inknots) + 2, 8) - nbasis = max(self.nbasis + other.nbasis, norder + 1) - return BSpline(self.domain_range, nbasis, norder) + norder = min(self.n_basis - len(self.inknots) + 2, 8) + n_basis = max(self.n_basis + other.n_basis, norder + 1) + return BSpline(self.domain_range, n_basis, norder) def rbasis_of_product(self, other): """Multiplication of a Bspline Basis with other basis""" - norder = min(self.nbasis - len(self.inknots) + 2, 8) - nbasis = max(self.nbasis + other.nbasis, norder + 1) - return BSpline(self.domain_range, nbasis, norder) + norder = min(self.n_basis - len(self.inknots) + 2, 8) + n_basis = max(self.n_basis + other.n_basis, norder + 1) + return BSpline(self.domain_range, n_basis, norder) def _to_R(self): drange = self.domain_range[0] return ("create.bspline.basis(rangeval = c(" + str(drange[0]) + "," + - str(drange[1]) + "), nbasis = " + str(self.nbasis) + + str(drange[1]) + "), n_basis = " + str(self.n_basis) + ", norder = " + str(self.order) + ", breaks = " + self._list_to_R(self.knots) + ")") @@ -1262,13 +1262,13 @@ class Fourier(Basis): Attributes: domain_range (tuple): A tuple of length 2 containing the initial and end values of the interval over which the basis can be evaluated. - nbasis (int): Number of functions in the basis. + n_basis (int): Number of functions in the basis. period (int or float): Period (:math:`T`). Examples: Constructs specifying number of basis, definition interval and period. - >>> fb = Fourier((0, np.pi), nbasis=3, period=1) + >>> fb = Fourier((0, np.pi), n_basis=3, period=1) >>> fb.evaluate([0, np.pi / 4, np.pi / 2, np.pi]).round(2) array([[ 1. , 1. , 1. , 1. ], [ 0. , -1.38, -0.61, 1.1 ], @@ -1286,16 +1286,16 @@ class Fourier(Basis): """ - def __init__(self, domain_range=None, nbasis=3, period=None): + def __init__(self, domain_range=None, n_basis=3, period=None): """Construct a Fourier object. - It forces the object to have an odd number of basis. If nbasis is + It forces the object to have an odd number of basis. If n_basis is even, it is incremented by one. Args: domain_range (tuple): Tuple defining the domain over which the function is defined. - nbasis (int): Number of basis functions. + n_basis (int): Number of basis functions. period (int or float): Period of the trigonometric functions that define the basis. @@ -1311,8 +1311,8 @@ def __init__(self, domain_range=None, nbasis=3, period=None): self.period = period # If number of basis is even, add 1 - nbasis += 1 - nbasis % 2 - super().__init__(domain_range, nbasis) + n_basis += 1 - n_basis % 2 + super().__init__(domain_range, n_basis) @property def period(self): @@ -1344,41 +1344,41 @@ def _compute_matrix(self, eval_points, derivative=0): omega = 2 * np.pi / self.period omega_t = omega * eval_points - nbasis = self.nbasis if self.nbasis % 2 != 0 else self.nbasis + 1 + n_basis = self.n_basis if self.n_basis % 2 != 0 else self.n_basis + 1 # Initialise empty matrix - mat = np.empty((self.nbasis, len(eval_points))) + mat = np.empty((self.n_basis, len(eval_points))) if derivative == 0: # First base function is a constant # The division by numpy.sqrt(2) is so that it has the same norm as # the sine and cosine: sqrt(period / 2) mat[0] = np.ones(len(eval_points)) / np.sqrt(2) - if nbasis > 1: + if n_basis > 1: # 2*pi*n*x / period - args = np.outer(range(1, nbasis // 2 + 1), omega_t) - index = range(1, nbasis - 1, 2) + args = np.outer(range(1, n_basis // 2 + 1), omega_t) + index = range(1, n_basis - 1, 2) # odd indexes are sine functions mat[index] = np.sin(args) - index = range(2, nbasis, 2) + index = range(2, n_basis, 2) # even indexes are cosine functions mat[index] = np.cos(args) # evaluates the derivatives else: # First base function is a constant, so its derivative is 0. mat[0] = np.zeros(len(eval_points)) - if nbasis > 1: + if n_basis > 1: # (2*pi*n / period) ^ n_derivative factor = np.outer( (-1) ** (derivative // 2) * - (np.array(range(1, nbasis // 2 + 1)) * omega) ** + (np.array(range(1, n_basis // 2 + 1)) * omega) ** derivative, np.ones(len(eval_points))) # 2*pi*n*x / period - args = np.outer(range(1, nbasis // 2 + 1), omega_t) + args = np.outer(range(1, n_basis // 2 + 1), omega_t) # even indexes - index_e = range(2, nbasis, 2) + index_e = range(2, n_basis, 2) # odd indexes - index_o = range(1, nbasis - 1, 2) + index_o = range(1, n_basis - 1, 2) if derivative % 2 == 0: mat[index_o] = factor * np.sin(args) mat[index_e] = factor * np.cos(args) @@ -1406,7 +1406,7 @@ def _ndegenerated(self, penalty_degree): def _derivative(self, coefs, order=1): omega = 2 * np.pi / self.period - deriv_factor = (np.arange(1, (self.nbasis + 1) / 2) * omega) ** order + deriv_factor = (np.arange(1, (self.n_basis + 1) / 2) * omega) ** order deriv_coefs = np.zeros(coefs.shape) @@ -1460,14 +1460,14 @@ def penalty(self, derivative_degree=None, coefficients=None): omega = 2 * np.pi / self.period # the derivatives of the functions of the basis are also orthogonal # so only the diagonal is different from 0. - penalty_matrix = np.zeros(self.nbasis) + penalty_matrix = np.zeros(self.n_basis) if derivative_degree == 0: penalty_matrix[0] = 1 else: # the derivative of a constant is 0 # the first basis function is a constant penalty_matrix[0] = 0 - index_even = np.array(range(2, self.nbasis, 2)) + index_even = np.array(range(2, self.n_basis, 2)) exponents = index_even / 2 # factor resulting of deriving the basis function the times # indcated in the derivative_degree @@ -1487,7 +1487,7 @@ def basis_of_product(self, other): raise ValueError("Ranges are not equal.") if isinstance(other, Fourier) and self.period == other.period: - return Fourier(self.domain_range, self.nbasis + other.nbasis - 1, + return Fourier(self.domain_range, self.n_basis + other.n_basis - 1, self.period) else: return other.rbasis_of_product(self) @@ -1526,13 +1526,13 @@ def rescale(self, domain_range=None, *, rescale_period=False): def _to_R(self): drange = self.domain_range[0] return ("create.fourier.basis(rangeval = c(" + str(drange[0]) + "," + - str(drange[1]) + "), nbasis = " + str(self.nbasis) + + str(drange[1]) + "), n_basis = " + str(self.n_basis) + ", period = " + str(self.period) + ")") def __repr__(self): """Representation of a Fourier basis.""" return (f"{self.__class__.__name__}(domain_range={self.domain_range}, " - f"nbasis={self.nbasis}, period={self.period})") + f"n_basis={self.n_basis}, period={self.period})") def __eq__(self, other): """Equality of Basis""" @@ -1561,11 +1561,11 @@ class FDataBasis(FData): functional datum. Examples: - >>> basis = Monomial(nbasis=4) + >>> basis = Monomial(n_basis=4) >>> coefficients = [1, 1, 3, .5] >>> FDataBasis(basis, coefficients) FDataBasis( - basis=Monomial(domain_range=[array([0, 1])], nbasis=4), + basis=Monomial(domain_range=[array([0, 1])], n_basis=4), coefficients=[[ 1. 1. 3. 0.5]], ...) @@ -1608,7 +1608,7 @@ def __init__(self, basis, coefficients, *, dataset_label=None, basis function in the basis. """ coefficients = np.atleast_2d(coefficients) - if coefficients.shape[1] != basis.nbasis: + if coefficients.shape[1] != basis.n_basis: raise ValueError("The length or number of columns of coefficients " "has to be the same equal to the number of " "elements of the basis.") @@ -1675,7 +1675,7 @@ def from_data(cls, data_matrix, sample_points, basis, >>> x array([ 1., 1., -1., -1., 1.]) - >>> basis = Fourier((0, 1), nbasis=3) + >>> basis = Fourier((0, 1), n_basis=3) >>> fd = FDataBasis.from_data(x, t, basis) >>> fd.coefficients.round(2) array([[ 0. , 0.71, 0.71]]) @@ -1746,9 +1746,9 @@ def coordinates(self): return FDataBasis._CoordinateIterator(self) @property - def nbasis(self): + def n_basis(self): """Return number of basis.""" - return self.basis.nbasis + return self.basis.n_basis @property def domain_range(self): @@ -1809,7 +1809,7 @@ def _evaluate_composed(self, eval_points, *, derivative=0): res_matrix = np.empty((self.n_samples, eval_points.shape[1])) - _matrix = np.empty((eval_points.shape[1], self.nbasis)) + _matrix = np.empty((eval_points.shape[1], self.n_basis)) for i in range(self.n_samples): basis_values = self.basis.evaluate(eval_points[i], derivative).T @@ -1852,7 +1852,7 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, domain_range = self.domain_range[0] if eval_points is None: # Grid to discretize the function - nfine = max(self.nbasis * 10 + 1, constants.N_POINTS_COARSE_MESH) + nfine = max(self.n_basis * 10 + 1, constants.N_POINTS_COARSE_MESH) eval_points = np.linspace(*domain_range, nfine) else: eval_points = np.asarray(eval_points) @@ -1925,11 +1925,11 @@ def mean(self, weights=None): FDataBasis object. Examples: - >>> basis = Monomial(nbasis=4) + >>> basis = Monomial(n_basis=4) >>> coefficients = [[0.5, 1, 2, .5], [1.5, 1, 4, .5]] >>> FDataBasis(basis, coefficients).mean() FDataBasis( - basis=Monomial(domain_range=[array([0, 1])], nbasis=4), + basis=Monomial(domain_range=[array([0, 1])], n_basis=4), coefficients=[[ 1. 1. 3. 0.5]], ...) @@ -2022,7 +2022,7 @@ def to_grid(self, eval_points=None): Examples: >>> fd = FDataBasis(coefficients=[[1, 1, 1], [1, 0, 1]], - ... basis=Monomial((0,5), nbasis=3)) + ... basis=Monomial((0,5), n_basis=3)) >>> fd.to_grid([0, 1, 2]) FDataGrid( array([[[ 1.], @@ -2043,7 +2043,7 @@ def to_grid(self, eval_points=None): if eval_points is None: npoints = max(constants.N_POINTS_FINE_MESH, - constants.BASIS_MIN_FACTOR * self.nbasis) + constants.BASIS_MIN_FACTOR * self.n_basis) eval_points = np.linspace(*self.domain_range[0], npoints) return grid.FDataGrid(self.evaluate(eval_points, keepdims=False), @@ -2123,7 +2123,7 @@ def times(self, other): basisobj = self.basis.basis_of_product(other.basis) neval = max(constants.BASIS_MIN_FACTOR * - max(self.nbasis, other.nbasis) + 1, + max(self.n_basis, other.n_basis) + 1, constants.N_POINTS_COARSE_MESH) (left, right) = self.domain_range[0] evalarg = np.linspace(left, right, neval) @@ -2202,7 +2202,7 @@ def inner_product(self, other, lfd_self=None, lfd_other=None, if weights is not None: other = other.times(weights) - if self.n_samples * other.n_samples > self.nbasis * other.nbasis: + if self.n_samples * other.n_samples > self.n_basis * other.n_basis: return (self.coefficients @ self.basis._inner_matrix(other.basis) @ other.coefficients.T) @@ -2265,7 +2265,7 @@ def __str__(self): """Return str(self).""" return (f"{self.__class__.__name__}(" - f"\nbasis={self.basis}," + f"\n_basis={self.basis}," f"\ncoefficients={self.coefficients})").replace('\n', '\n ') def __eq__(self, other): diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 29b9f9071..475249cfa 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -847,7 +847,7 @@ def to_basis(self, basis, **kwargs): array([ 1., 1., -1., -1., 1.]) >>> fd = FDataGrid(x, t) - >>> basis = skfda.representation.basis.Fourier(nbasis=3) + >>> basis = skfda.representation.basis.Fourier(n_basis=3) >>> fd_b = fd.to_basis(basis) >>> fd_b.coefficients.round(2) array([[ 0. , 0.71, 0.71]]) diff --git a/tests/test_basis.py b/tests/test_basis.py index 9830be27c..28cef06c5 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -12,7 +12,7 @@ class TestBasis(unittest.TestCase): def test_from_data_cholesky(self): t = np.linspace(0, 1, 5) x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) - basis = BSpline((0, 1), nbasis=5) + basis = BSpline((0, 1), n_basis=5) np.testing.assert_array_almost_equal( FDataBasis.from_data(x, t, basis, method='cholesky' ).coefficients.round(2), @@ -22,7 +22,7 @@ def test_from_data_cholesky(self): def test_from_data_qr(self): t = np.linspace(0, 1, 5) x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) - basis = BSpline((0, 1), nbasis=5) + basis = BSpline((0, 1), n_basis=5) np.testing.assert_array_almost_equal( FDataBasis.from_data(x, t, basis, method='qr' ).coefficients.round(2), @@ -30,7 +30,7 @@ def test_from_data_qr(self): ) def test_bspline_penalty_special_case(self): - basis = BSpline(nbasis=5) + basis = BSpline(n_basis=5) np.testing.assert_array_almost_equal( basis.penalty(basis.order - 1), np.array([[1152., -2016., 1152., -288., 0.], @@ -40,7 +40,7 @@ def test_bspline_penalty_special_case(self): [0., -288., 1152., -2016., 1152.]])) def test_fourier_penalty(self): - basis = Fourier(nbasis=5) + basis = Fourier(n_basis=5) np.testing.assert_array_almost_equal( basis.penalty(2).round(2), np.array([[0., 0., 0., 0., 0.], @@ -50,7 +50,7 @@ def test_fourier_penalty(self): [0., 0., 0., 0., 24936.73]])) def test_bspline_penalty(self): - basis = BSpline(nbasis=5) + basis = BSpline(n_basis=5) np.testing.assert_array_almost_equal( basis.penalty(2).round(2), np.array([[96., -132., 24., 12., 0.], @@ -60,7 +60,7 @@ def test_bspline_penalty(self): [0., 12., 24., -132., 96.]])) def test_bspline_penalty_numerical(self): - basis = BSpline(nbasis=5) + basis = BSpline(n_basis=5) np.testing.assert_array_almost_equal( basis.penalty(coefficients=[0, 0, 1]).round(2), np.array([[96., -132., 24., 12., 0.], @@ -70,9 +70,9 @@ def test_bspline_penalty_numerical(self): [0., 12., 24., -132., 96.]])) def test_basis_product_generic(self): - monomial = Monomial(nbasis=5) - fourier = Fourier(nbasis=3) - prod = BSpline(nbasis=9, order=8) + monomial = Monomial(n_basis=5) + fourier = Fourier(n_basis=3) + prod = BSpline(n_basis=9, order=8) self.assertEqual(Basis.default_basis_of_product( monomial, fourier), prod) @@ -80,7 +80,7 @@ def test_basis_constant_product(self): constant = Constant() monomial = Monomial() fourier = Fourier() - bspline = BSpline(nbasis=5, order=3) + bspline = BSpline(n_basis=5, order=3) self.assertEqual(constant.basis_of_product(monomial), monomial) self.assertEqual(constant.basis_of_product(fourier), fourier) self.assertEqual(constant.basis_of_product(bspline), bspline) @@ -90,48 +90,48 @@ def test_basis_constant_product(self): def test_basis_fourier_product(self): # Test when periods are the same - fourier = Fourier(nbasis=5) - fourier2 = Fourier(nbasis=3) - prod = Fourier(nbasis=7) + fourier = Fourier(n_basis=5) + fourier2 = Fourier(n_basis=3) + prod = Fourier(n_basis=7) self.assertEqual(fourier.basis_of_product(fourier2), prod) # Test when periods are different - fourier2 = Fourier(nbasis=3, period=2) - prod = BSpline(nbasis=9, order=8) + fourier2 = Fourier(n_basis=3, period=2) + prod = BSpline(n_basis=9, order=8) self.assertEqual(fourier.basis_of_product(fourier2), prod) def test_basis_monomial_product(self): - monomial = Monomial(nbasis=5) - monomial2 = Monomial(nbasis=3) - prod = Monomial(nbasis=8) + monomial = Monomial(n_basis=5) + monomial2 = Monomial(n_basis=3) + prod = Monomial(n_basis=8) self.assertEqual(monomial.basis_of_product(monomial2), prod) def test_basis_bspline_product(self): - bspline = BSpline(nbasis=6, order=4) - bspline2 = BSpline(domain_range=(0, 1), nbasis=6, + bspline = BSpline(n_basis=6, order=4) + bspline2 = BSpline(domain_range=(0, 1), n_basis=6, order=4, knots=[0, 0.3, 1 / 3, 1]) - prod = BSpline(domain_range=(0, 1), nbasis=10, order=7, + prod = BSpline(domain_range=(0, 1), n_basis=10, order=7, knots=[0, 0.3, 1 / 3, 2 / 3, 1]) self.assertEqual(bspline.basis_of_product(bspline2), prod) def test_basis_inner_matrix(self): - np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(), + np.testing.assert_array_almost_equal(Monomial(n_basis=3)._inner_matrix(), [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) - np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(Monomial(nbasis=3)), + np.testing.assert_array_almost_equal(Monomial(n_basis=3)._inner_matrix(Monomial(n_basis=3)), [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) - np.testing.assert_array_almost_equal(Monomial(nbasis=3)._inner_matrix(Monomial(nbasis=4)), + np.testing.assert_array_almost_equal(Monomial(n_basis=3)._inner_matrix(Monomial(n_basis=4)), [[1, 1 / 2, 1 / 3, 1 / 4], [1 / 2, 1 / 3, 1 / 4, 1 / 5], [1 / 3, 1 / 4, 1 / 5, 1 / 6]]) # TODO testing with other basis def test_basis_gram_matrix(self): - np.testing.assert_array_almost_equal(Monomial(nbasis=3).gram_matrix(), + np.testing.assert_array_almost_equal(Monomial(n_basis=3).gram_matrix(), [[1, 1 / 2, 1 / 3], [1 / 2, 1 / 3, 1 / 4], [1 / 3, 1 / 4, 1 / 5]]) - np.testing.assert_almost_equal(Fourier(nbasis=3).gram_matrix(), + np.testing.assert_almost_equal(Fourier(n_basis=3).gram_matrix(), np.identity(3)) - np.testing.assert_almost_equal(BSpline(nbasis=6).gram_matrix().round(4), + np.testing.assert_almost_equal(BSpline(n_basis=6).gram_matrix().round(4), np.array([[4.760e-02, 2.920e-02, 6.200e-03, 4.000e-04, 0.000e+00, 0.000e+00], [2.920e-02, 7.380e-02, 5.210e-02, 1.150e-02, 1.000e-04, 0.000e+00], @@ -144,8 +144,8 @@ def test_basis_gram_matrix(self): [0.000e+00, 0.000e+00, 4.000e-04, 6.200e-03, 2.920e-02, 4.760e-02]])) def test_basis_basis_inprod(self): - monomial = Monomial(nbasis=4) - bspline = BSpline(nbasis=5, order=4) + monomial = Monomial(n_basis=4) + bspline = BSpline(n_basis=5, order=4) np.testing.assert_array_almost_equal( monomial.inner_product(bspline).round(3), np.array( @@ -161,8 +161,8 @@ def test_basis_basis_inprod(self): ) def test_basis_fdatabasis_inprod(self): - monomial = Monomial(nbasis=4) - bspline = BSpline(nbasis=5, order=3) + monomial = Monomial(n_basis=4) + bspline = BSpline(n_basis=5, order=3) bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) np.testing.assert_array_almost_equal( @@ -174,13 +174,13 @@ def test_basis_fdatabasis_inprod(self): ) def test_fdatabasis_fdatabasis_inprod(self): - monomial = Monomial(nbasis=4) + monomial = Monomial(n_basis=4) monomialfd = FDataBasis(monomial, [[5, 4, 1, 0], [4, 2, 1, 0], [4, 1, 6, 4], [4, 5, 0, 1], [5, 6, 2, 0]]) - bspline = BSpline(nbasis=5, order=3) + bspline = BSpline(n_basis=5, order=3) bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) np.testing.assert_array_almost_equal( @@ -203,8 +203,8 @@ def test_fdatabasis_fdatabasis_inprod(self): ) def test_comutativity_inprod(self): - monomial = Monomial(nbasis=4) - bspline = BSpline(nbasis=5, order=3) + monomial = Monomial(n_basis=4) + bspline = BSpline(n_basis=5, order=3) bsplinefd = FDataBasis(bspline, np.arange(0, 15).reshape(3, 5)) np.testing.assert_array_almost_equal( @@ -213,11 +213,11 @@ def test_comutativity_inprod(self): ) def test_fdatabasis_times_fdatabasis_fdatabasis(self): - monomial = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) - bspline = FDataBasis(BSpline(nbasis=6, order=4), [1, 2, 4, 1, 0, 1]) + monomial = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) + bspline = FDataBasis(BSpline(n_basis=6, order=4), [1, 2, 4, 1, 0, 1]) times_fdar = monomial.times(bspline) - prod_basis = BSpline(nbasis=9, order=6, knots=[0, 0.25, 0.5, 0.75, 1]) + prod_basis = BSpline(n_basis=9, order=6, knots=[0, 0.25, 0.5, 0.75, 1]) prod_coefs = np.array([[0.9788352, 1.6289955, 2.7004969, 6.2678739, 8.7636441, 4.0069960, 0.7126961, 2.8826708, 6.0052311]]) @@ -227,176 +227,176 @@ def test_fdatabasis_times_fdatabasis_fdatabasis(self): prod_coefs, times_fdar.coefficients) def test_fdatabasis_times_fdatabasis_list(self): - monomial = FDataBasis(Monomial(nbasis=3), + monomial = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [4, 5, 6], [7, 8, 9]]) result = monomial.times([3, 2, 1]) - expec_basis = Monomial(nbasis=3) + expec_basis = Monomial(n_basis=3) expec_coefs = np.array([[3, 6, 9], [8, 10, 12], [7, 8, 9]]) self.assertEqual(expec_basis, result.basis) np.testing.assert_array_almost_equal(expec_coefs, result.coefficients) def test_fdatabasis_times_fdatabasis_int(self): - monomial = FDataBasis(Monomial(nbasis=3), + monomial = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [4, 5, 6], [7, 8, 9]]) result = monomial.times(3) - expec_basis = Monomial(nbasis=3) + expec_basis = Monomial(n_basis=3) expec_coefs = np.array([[3, 6, 9], [12, 15, 18], [21, 24, 27]]) self.assertEqual(expec_basis, result.basis) np.testing.assert_array_almost_equal(expec_coefs, result.coefficients) def test_fdatabasis__add__(self): - monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) - monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) np.testing.assert_equal(monomial1 + monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[2, 4, 6], [4, 6, 8]])) np.testing.assert_equal(monomial2 + 1, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[2, 2, 3], [4, 4, 5]])) np.testing.assert_equal(1 + monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[2, 2, 3], [4, 4, 5]])) np.testing.assert_equal(monomial2 + [1, 2], - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[2, 2, 3], [5, 4, 5]])) np.testing.assert_equal([1, 2] + monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[2, 2, 3], [5, 4, 5]])) np.testing.assert_raises(NotImplementedError, monomial2.__add__, - FDataBasis(Fourier(nbasis=3), + FDataBasis(Fourier(n_basis=3), [[2, 2, 3], [5, 4, 5]])) def test_fdatabasis__sub__(self): - monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) - monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) np.testing.assert_equal(monomial1 - monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[0, 0, 0], [-2, -2, -2]])) np.testing.assert_equal(monomial2 - 1, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[0, 2, 3], [2, 4, 5]])) np.testing.assert_equal(1 - monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[0, -2, -3], [-2, -4, -5]])) np.testing.assert_equal(monomial2 - [1, 2], - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[0, 2, 3], [1, 4, 5]])) np.testing.assert_equal([1, 2] - monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[0, -2, -3], [-1, -4, -5]])) np.testing.assert_raises(NotImplementedError, monomial2.__sub__, - FDataBasis(Fourier(nbasis=3), + FDataBasis(Fourier(n_basis=3), [[2, 2, 3], [5, 4, 5]])) def test_fdatabasis__mul__(self): - monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) - monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) np.testing.assert_equal(monomial1 * 2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[2, 4, 6]])) np.testing.assert_equal(3 * monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[3, 6, 9], [9, 12, 15]])) np.testing.assert_equal(3 * monomial2, monomial2 * 3) np.testing.assert_equal(monomial2 * [1, 2], - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [6, 8, 10]])) np.testing.assert_equal([1, 2] * monomial2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [6, 8, 10]])) np.testing.assert_raises(NotImplementedError, monomial2.__mul__, - FDataBasis(Fourier(nbasis=3), + FDataBasis(Fourier(n_basis=3), [[2, 2, 3], [5, 4, 5]])) np.testing.assert_raises(NotImplementedError, monomial2.__mul__, monomial2) def test_fdatabasis__mul__(self): - monomial1 = FDataBasis(Monomial(nbasis=3), [1, 2, 3]) - monomial2 = FDataBasis(Monomial(nbasis=3), [[1, 2, 3], [3, 4, 5]]) + monomial1 = FDataBasis(Monomial(n_basis=3), [1, 2, 3]) + monomial2 = FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3, 4, 5]]) np.testing.assert_equal(monomial1 / 2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[1 / 2, 1, 3 / 2]])) np.testing.assert_equal(monomial2 / 2, - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[1 / 2, 1, 3 / 2], [3 / 2, 2, 5 / 2]])) np.testing.assert_equal(monomial2 / [1, 2], - FDataBasis(Monomial(nbasis=3), + FDataBasis(Monomial(n_basis=3), [[1, 2, 3], [3 / 2, 2, 5 / 2]])) def test_fdatabasis_derivative_constant(self): - monomial = FDataBasis(Monomial(nbasis=8), + monomial = FDataBasis(Monomial(n_basis=8), [1, 5, 8, 9, 7, 8, 4, 5]) - monomial2 = FDataBasis(Monomial(nbasis=5), + monomial2 = FDataBasis(Monomial(n_basis=5), [[4, 9, 7, 4, 3], [1, 7, 9, 8, 5], [4, 6, 6, 6, 8]]) np.testing.assert_equal(monomial.derivative(), - FDataBasis(Monomial(nbasis=7), + FDataBasis(Monomial(n_basis=7), [5, 16, 27, 28, 40, 24, 35])) np.testing.assert_equal(monomial.derivative(order=0), monomial) np.testing.assert_equal(monomial.derivative(order=6), - FDataBasis(Monomial(nbasis=2), + FDataBasis(Monomial(n_basis=2), [2880, 25200])) np.testing.assert_equal(monomial2.derivative(), - FDataBasis(Monomial(nbasis=4), + FDataBasis(Monomial(n_basis=4), [[9, 14, 12, 12], [7, 18, 24, 20], [6, 12, 18, 32]])) np.testing.assert_equal(monomial2.derivative(order=0), monomial2) np.testing.assert_equal(monomial2.derivative(order=3), - FDataBasis(Monomial(nbasis=2), + FDataBasis(Monomial(n_basis=2), [[24, 72], [48, 120], [36, 192]])) def test_fdatabasis_derivative_monomial(self): - monomial = FDataBasis(Monomial(nbasis=8), + monomial = FDataBasis(Monomial(n_basis=8), [1, 5, 8, 9, 7, 8, 4, 5]) - monomial2 = FDataBasis(Monomial(nbasis=5), + monomial2 = FDataBasis(Monomial(n_basis=5), [[4, 9, 7, 4, 3], [1, 7, 9, 8, 5], [4, 6, 6, 6, 8]]) np.testing.assert_equal(monomial.derivative(), - FDataBasis(Monomial(nbasis=7), + FDataBasis(Monomial(n_basis=7), [5, 16, 27, 28, 40, 24, 35])) np.testing.assert_equal(monomial.derivative(order=0), monomial) np.testing.assert_equal(monomial.derivative(order=6), - FDataBasis(Monomial(nbasis=2), + FDataBasis(Monomial(n_basis=2), [2880, 25200])) np.testing.assert_equal(monomial2.derivative(), - FDataBasis(Monomial(nbasis=4), + FDataBasis(Monomial(n_basis=4), [[9, 14, 12, 12], [7, 18, 24, 20], [6, 12, 18, 32]])) np.testing.assert_equal(monomial2.derivative(order=0), monomial2) np.testing.assert_equal(monomial2.derivative(order=3), - FDataBasis(Monomial(nbasis=2), + FDataBasis(Monomial(n_basis=2), [[24, 72], [48, 120], [36, 192]])) def test_fdatabasis_derivative_fourier(self): - fourier = FDataBasis(Fourier(nbasis=7), + fourier = FDataBasis(Fourier(n_basis=7), [1, 5, 8, 9, 8, 4, 5]) - fourier2 = FDataBasis(Fourier(nbasis=5), + fourier2 = FDataBasis(Fourier(n_basis=5), [[4, 9, 7, 4, 3], [1, 7, 9, 8, 5], [4, 6, 6, 6, 8]]) @@ -436,9 +436,9 @@ def test_fdatabasis_derivative_fourier(self): [0, -236.87051, -236.87051, -947.48202, -1263.30936]]) def test_fdatabasis_derivative_bspline(self): - bspline = FDataBasis(BSpline(nbasis=8), + bspline = FDataBasis(BSpline(n_basis=8), [1, 5, 8, 9, 7, 8, 4, 5]) - bspline2 = FDataBasis(BSpline(nbasis=5), + bspline2 = FDataBasis(BSpline(n_basis=5), [[4, 9, 7, 4, 3], [1, 7, 9, 8, 5], [4, 6, 6, 6, 8]]) @@ -446,12 +446,12 @@ def test_fdatabasis_derivative_bspline(self): bs0 = bspline.derivative(order=0) bs1 = bspline.derivative() bs2 = bspline.derivative(order=2) - np.testing.assert_equal(bs1.basis, BSpline(nbasis=7, order=3)) + np.testing.assert_equal(bs1.basis, BSpline(n_basis=7, order=3)) np.testing.assert_almost_equal(bs1.coefficients, np.atleast_2d([60, 22.5, 5, -10, 5, -30, 15])) np.testing.assert_equal(bs0, bspline) - np.testing.assert_equal(bs2.basis, BSpline(nbasis=6, order=2)) + np.testing.assert_equal(bs2.basis, BSpline(n_basis=6, order=2)) np.testing.assert_almost_equal(bs2.coefficients, np.atleast_2d([-375, -87.5, -75, 75, -175, 450])) @@ -460,13 +460,13 @@ def test_fdatabasis_derivative_bspline(self): bs1 = bspline2.derivative() bs2 = bspline2.derivative(order=2) - np.testing.assert_equal(bs1.basis, BSpline(nbasis=4, order=3)) + np.testing.assert_equal(bs1.basis, BSpline(n_basis=4, order=3)) np.testing.assert_almost_equal(bs1.coefficients, [[30, -6, -9, -6], [36, 6, -3, -18], [12, 0, 0, 12]]) np.testing.assert_equal(bs0, bspline2) - np.testing.assert_equal(bs2.basis, BSpline(nbasis=3, order=2)) + np.testing.assert_equal(bs2.basis, BSpline(n_basis=3, order=2)) np.testing.assert_almost_equal(bs2.coefficients, [[-144, -6, 12], [-120, -18, -60], diff --git a/tests/test_basis_evaluation.py b/tests/test_basis_evaluation.py index e3cb49e10..05a95edf5 100644 --- a/tests/test_basis_evaluation.py +++ b/tests/test_basis_evaluation.py @@ -1,14 +1,15 @@ import unittest -from skfda.representation.basis import FDataBasis, Monomial, BSpline, Fourier + import numpy as np +from skfda.representation.basis import FDataBasis, Monomial, BSpline, Fourier class TestBasisEvaluationFourier(unittest.TestCase): def test_evaluation_simple_fourier(self): """Test the evaluation of FDataBasis""" - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -27,7 +28,7 @@ def test_evaluation_simple_fourier(self): def test_evaluation_point_fourier(self): """Test the evaluation of a single point FDataBasis""" - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -48,7 +49,7 @@ def test_evaluation_point_fourier(self): def test_evaluation_derivative_fourier(self): """Test the evaluation of the derivative of a FDataBasis""" - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -70,7 +71,7 @@ def test_evaluation_grid_fourier(self): """Test the evaluation of FDataBasis with the grid option set to true. Nothing should be change due to the domain dimension is 1, but can accept the """ - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -94,7 +95,7 @@ def test_evaluation_grid_fourier(self): def test_evaluation_composed_fourier(self): """Test the evaluation of FDataBasis the a matrix of times instead of a list of times """ - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -121,7 +122,7 @@ def test_evaluation_composed_fourier(self): def test_evaluation_keepdims_fourier(self): """Test behaviour of keepdims """ - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -160,7 +161,7 @@ def test_evaluation_keepdims_fourier(self): def test_evaluation_composed_keepdims_fourier(self): """Test behaviour of keepdims with composed evaluation""" - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -205,7 +206,7 @@ def test_evaluation_composed_keepdims_fourier(self): def test_evaluation_grid_keepdims_fourier(self): """Test behaviour of keepdims with grid evaluation""" - fourier = Fourier(domain_range=(0, 1), nbasis=3) + fourier = Fourier(domain_range=(0, 1), n_basis=3) coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -248,10 +249,10 @@ def test_evaluation_grid_keepdims_fourier(self): def test_domain_in_list_fourier(self): """Test the evaluation of FDataBasis""" - for fourier in (Fourier(domain_range=[(0, 1)], nbasis=3), - Fourier(domain_range=((0, 1),), nbasis=3), - Fourier(domain_range=np.array((0, 1)), nbasis=3), - Fourier(domain_range=np.array([(0, 1)]), nbasis=3)): + for fourier in (Fourier(domain_range=[(0, 1)], n_basis=3), + Fourier(domain_range=((0, 1),), n_basis=3), + Fourier(domain_range=np.array((0, 1)), n_basis=3), + Fourier(domain_range=np.array([(0, 1)]), n_basis=3)): coefficients = np.array([[0.00078238, 0.48857741, 0.63971985], [0.01778079, 0.73440271, 0.20148638]]) @@ -271,7 +272,7 @@ class TestBasisEvaluationBSpline(unittest.TestCase): def test_evaluation_simple_bspline(self): """Test the evaluation of FDataBasis""" - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -288,7 +289,7 @@ def test_evaluation_simple_bspline(self): def test_evaluation_point_bspline(self): """Test the evaluation of a single point FDataBasis""" - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -308,7 +309,7 @@ def test_evaluation_point_bspline(self): def test_evaluation_derivative_bspline(self): """Test the evaluation of the derivative of a FDataBasis""" - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -327,7 +328,7 @@ def test_evaluation_grid_bspline(self): """Test the evaluation of FDataBasis with the grid option set to true. Nothing should be change due to the domain dimension is 1, but can accept the """ - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -351,7 +352,7 @@ def test_evaluation_grid_bspline(self): def test_evaluation_composed_bspline(self): """Test the evaluation of FDataBasis the a matrix of times instead of a list of times """ - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -379,7 +380,7 @@ def test_evaluation_composed_bspline(self): def test_evaluation_keepdims_bspline(self): """Test behaviour of keepdims """ - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -416,7 +417,7 @@ def test_evaluation_keepdims_bspline(self): def test_evaluation_composed_keepdims_bspline(self): """Test behaviour of keepdims with composed evaluation""" - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -457,7 +458,7 @@ def test_evaluation_composed_keepdims_bspline(self): def test_evaluation_grid_keepdims_bspline(self): """Test behaviour of keepdims with grid evaluation""" - bspline = BSpline(domain_range=(0, 1), nbasis=5, order=3) + bspline = BSpline(domain_range=(0, 1), n_basis=5, order=3) coefficients = [[0.00078238, 0.48857741, 0.63971985, 0.23, 0.33], [0.01778079, 0.73440271, 0.20148638, 0.54, 0.12]] @@ -496,11 +497,11 @@ def test_evaluation_grid_keepdims_bspline(self): def test_domain_in_list_bspline(self): """Test the evaluation of FDataBasis""" - for bspline in (BSpline(domain_range=[(0, 1)], nbasis=5, order=3), - BSpline(domain_range=((0, 1),), nbasis=5, order=3), - BSpline(domain_range=np.array((0, 1)), nbasis=5, + for bspline in (BSpline(domain_range=[(0, 1)], n_basis=5, order=3), + BSpline(domain_range=((0, 1),), n_basis=5, order=3), + BSpline(domain_range=np.array((0, 1)), n_basis=5, order=3), - BSpline(domain_range=np.array([(0, 1)]), nbasis=5, + BSpline(domain_range=np.array([(0, 1)]), n_basis=5, order=3) ): @@ -527,7 +528,7 @@ class TestBasisEvaluationMonomial(unittest.TestCase): def test_evaluation_simple_monomial(self): """Test the evaluation of FDataBasis""" - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -543,7 +544,7 @@ def test_evaluation_simple_monomial(self): def test_evaluation_point_monomial(self): """Test the evaluation of a single point FDataBasis""" - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -562,7 +563,7 @@ def test_evaluation_point_monomial(self): def test_evaluation_derivative_monomial(self): """Test the evaluation of the derivative of a FDataBasis""" - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -580,7 +581,7 @@ def test_evaluation_grid_monomial(self): """Test the evaluation of FDataBasis with the grid option set to true. Nothing should be change due to the domain dimension is 1, but can accept the """ - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -603,7 +604,7 @@ def test_evaluation_grid_monomial(self): def test_evaluation_composed_monomial(self): """Test the evaluation of FDataBasis the a matrix of times instead of a list of times """ - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -629,7 +630,7 @@ def test_evaluation_composed_monomial(self): def test_evaluation_keepdims_monomial(self): """Test behaviour of keepdims """ - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -663,7 +664,7 @@ def test_evaluation_keepdims_monomial(self): def test_evaluation_composed_keepdims_monomial(self): """Test behaviour of keepdims with composed evaluation""" - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -700,7 +701,7 @@ def test_evaluation_composed_keepdims_monomial(self): def test_evaluation_grid_keepdims_monomial(self): """Test behaviour of keepdims with grid evaluation""" - monomial = Monomial(domain_range=(0, 1), nbasis=3) + monomial = Monomial(domain_range=(0, 1), n_basis=3) coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] @@ -737,10 +738,10 @@ def test_evaluation_grid_keepdims_monomial(self): def test_domain_in_list_monomial(self): """Test the evaluation of FDataBasis""" - for monomial in (Monomial(domain_range=[(0, 1)], nbasis=3), - Monomial(domain_range=((0, 1),), nbasis=3), - Monomial(domain_range=np.array((0, 1)), nbasis=3), - Monomial(domain_range=np.array([(0, 1)]), nbasis=3)): + for monomial in (Monomial(domain_range=[(0, 1)], n_basis=3), + Monomial(domain_range=((0, 1),), n_basis=3), + Monomial(domain_range=np.array((0, 1)), n_basis=3), + Monomial(domain_range=np.array([(0, 1)]), n_basis=3)): coefficients = [[1, 2, 3], [0.5, 1.4, 1.3]] diff --git a/tests/test_extrapolation.py b/tests/test_extrapolation.py index 606355c83..993da57db 100644 --- a/tests/test_extrapolation.py +++ b/tests/test_extrapolation.py @@ -3,10 +3,9 @@ import unittest import numpy as np - +from skfda import FDataGrid, FDataBasis from skfda.datasets import make_sinusoidal_process from skfda.representation.basis import Fourier -from skfda import FDataGrid, FDataBasis from skfda.representation.extrapolation import ( PeriodicExtrapolation, BoundaryExtrapolation, ExceptionExtrapolation, FillExtrapolation) @@ -21,7 +20,7 @@ def setUp(self): def test_constructor_FDataBasis_setting(self): coeff = self.dummy_data - basis = Fourier(nbasis=3) + basis = Fourier(n_basis=3) a = FDataBasis(basis, coeff) np.testing.assert_equal(a.extrapolation, None) @@ -45,7 +44,7 @@ def test_constructor_FDataBasis_setting(self): def test_FDataBasis_setting(self): coeff = self.dummy_data - basis = Fourier(nbasis=3) + basis = Fourier(n_basis=3) a = FDataBasis(basis, coeff) a.extrapolation = "periodic" diff --git a/tests/test_lfd.py b/tests/test_lfd.py index c92cc24ea..3a0f6e920 100644 --- a/tests/test_lfd.py +++ b/tests/test_lfd.py @@ -1,9 +1,8 @@ import unittest import numpy as np - -from skfda.representation.basis import FDataBasis, Constant, Monomial from skfda.misc import LinearDifferentialOperator +from skfda.representation.basis import FDataBasis, Constant, Monomial class TestBasis(unittest.TestCase): @@ -47,7 +46,7 @@ def test_init_list_int(self): def test_init_list_fdatabasis(self): weights = np.arange(4 * 5).reshape((5, 4)) - monomial = Monomial((0, 1), nbasis=4) + monomial = Monomial((0, 1), n_basis=4) fd = FDataBasis(monomial, weights) fdlist = [FDataBasis(monomial, weights[i]) diff --git a/tests/test_metrics.py b/tests/test_metrics.py index 0ab4aebda..aa6dc39f8 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -16,7 +16,7 @@ def setUp(self): sample_points = [1, 2, 3, 4, 5] self.fd = FDataGrid([[2, 3, 4, 5, 6], [1, 4, 9, 16, 25]], sample_points=sample_points) - basis = Monomial(nbasis=3, domain_range=(1, 5)) + basis = Monomial(n_basis=3, domain_range=(1, 5)) self.fd_basis = FDataBasis(basis, [[1, 1, 0], [0, 0, 1]]) self.fd_curve = self.fd.concatenate(self.fd, as_coordinates=True) self.fd_surface = make_multimodal_samples(n_samples=3, dim_domain=2, diff --git a/tests/test_neighbors.py b/tests/test_neighbors.py index d4df75fdc..98199da0e 100644 --- a/tests/test_neighbors.py +++ b/tests/test_neighbors.py @@ -153,7 +153,7 @@ def test_knn_functional_response(self): def test_knn_functional_response_sklearn(self): # Check sklearn metric knnr = KNeighborsRegressor(n_neighbors=1, metric='euclidean', - multivariate_metric=True) + multivariate_metric=True) knnr.fit(self.X, self.X) res = knnr.predict(self.X) @@ -162,7 +162,7 @@ def test_knn_functional_response_sklearn(self): def test_knn_functional_response_precomputed(self): knnr = KNeighborsRegressor(n_neighbors=4, weights='distance', - metric='precomputed') + metric='precomputed') d = pairwise_distance(lp_distance) distances = d(self.X[:4], self.X[:4]) @@ -174,8 +174,8 @@ def test_knn_functional_response_precomputed(self): def test_radius_functional_response(self): knnr = RadiusNeighborsRegressor(metric=lp_distance, - weights='distance', - regressor=l2_mean) + weights='distance', + regressor=l2_mean) knnr.fit(self.X, self.X) @@ -190,7 +190,7 @@ def weights(weights): return np.array([w == 0 for w in weights], dtype=float) knnr = KNeighborsRegressor(weights=weights, n_neighbors=5) - response = self.X.to_basis(Fourier(domain_range=(-1, 1), nbasis=10)) + response = self.X.to_basis(Fourier(domain_range=(-1, 1), n_basis=10)) knnr.fit(self.X, response) res = knnr.predict(self.X) @@ -216,7 +216,7 @@ def test_functional_regression_distance_weights(self): def test_functional_response_basis(self): knnr = KNeighborsRegressor(weights='distance', n_neighbors=5) - response = self.X.to_basis(Fourier(domain_range=(-1, 1), nbasis=10)) + response = self.X.to_basis(Fourier(domain_range=(-1, 1), n_basis=10)) knnr.fit(self.X, response) res = knnr.predict(self.X) @@ -233,7 +233,7 @@ def test_radius_outlier_functional_response(self): # Test response knnr = RadiusNeighborsRegressor(radius=0.001, - outlier_response=self.X[0]) + outlier_response=self.X[0]) knnr.fit(self.X[:6], self.X[:6]) res = knnr.predict(self.X[:7]) @@ -290,7 +290,6 @@ def test_score_scalar_response(self): r = neigh.score(self.X, self.modes_location) np.testing.assert_almost_equal(r, 0.9975889963743335) - def test_score_functional_response(self): neigh = KNeighborsRegressor() @@ -301,7 +300,7 @@ def test_score_functional_response(self): np.testing.assert_almost_equal(r, 0.962651178452408) # Weighted case and basis form - y = y.to_basis(Fourier(domain_range=y.domain_range[0], nbasis=5)) + y = y.to_basis(Fourier(domain_range=y.domain_range[0], n_basis=5)) neigh.fit(self.X, y) r = neigh.score(self.X[:7], y[:7], diff --git a/tests/test_pandas.py b/tests/test_pandas.py index 6f1b78530..5c0247a38 100644 --- a/tests/test_pandas.py +++ b/tests/test_pandas.py @@ -10,7 +10,7 @@ def setUp(self): self.fd = skfda.FDataGrid( [[1, 2, 3, 4, 5, 6, 7], [2, 3, 4, 5, 6, 7, 9]]) self.fd_basis = self.fd.to_basis(skfda.representation.basis.BSpline( - nbasis=5)) + n_basis=5)) def test_fdatagrid_series(self): series = pd.Series(self.fd) diff --git a/tests/test_regression.py b/tests/test_regression.py index 5bd093a1d..3531df513 100644 --- a/tests/test_regression.py +++ b/tests/test_regression.py @@ -1,18 +1,19 @@ import unittest + +import numpy as np +from skfda.ml.regression import LinearScalarRegression from skfda.representation.basis import (FDataBasis, Constant, Monomial, Fourier, BSpline) -from skfda.ml.regression import LinearScalarRegression -import numpy as np class TestLinearScalarRegression(unittest.TestCase): def test_regression_fit(self): - x_basis = Monomial(nbasis=7) + x_basis = Monomial(n_basis=7) x_fd = FDataBasis(x_basis, np.identity(7)) - beta_basis = Fourier(nbasis=5) + beta_basis = Fourier(n_basis=5) beta_fd = FDataBasis(beta_basis, [1, 1, 1, 1, 1]) y = [1.0000684777229512, 0.1623672257830915, @@ -29,10 +30,10 @@ def test_regression_fit(self): def test_regression_predict_single_explanatory(self): - x_basis = Monomial(nbasis=7) + x_basis = Monomial(n_basis=7) x_fd = FDataBasis(x_basis, np.identity(7)) - beta_basis = Fourier(nbasis=5) + beta_basis = Fourier(n_basis=5) beta_fd = FDataBasis(beta_basis, [1, 1, 1, 1, 1]) y = [1.0000684777229512, 0.1623672257830915, @@ -51,10 +52,10 @@ def test_regression_predict_multiple_explanatory(self): y = [1, 2, 3, 4, 5, 6, 7] x0 = FDataBasis(Constant(domain_range=(0, 1)), np.ones((7, 1))) - x1 = FDataBasis(Monomial(nbasis=7), np.identity(7)) + x1 = FDataBasis(Monomial(n_basis=7), np.identity(7)) beta0 = Constant(domain_range=(0, 1)) - beta1 = BSpline(domain_range=(0, 1), nbasis=5) + beta1 = BSpline(domain_range=(0, 1), n_basis=5) scalar = LinearScalarRegression([beta0, beta1]) @@ -79,17 +80,17 @@ def test_error_X_not_FData(self): x_fd = np.identity(7) y = np.zeros(7) - scalar = LinearScalarRegression([Fourier(nbasis=5)]) + scalar = LinearScalarRegression([Fourier(n_basis=5)]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) def test_error_y_is_FData(self): """Tests that none of the explained variables is an FData object """ - x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) - y = list(FDataBasis(Monomial(nbasis=7), np.identity(7))) + x_fd = FDataBasis(Monomial(n_basis=7), np.identity(7)) + y = list(FDataBasis(Monomial(n_basis=7), np.identity(7))) - scalar = LinearScalarRegression([Fourier(nbasis=5)]) + scalar = LinearScalarRegression([Fourier(n_basis=5)]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) @@ -97,9 +98,9 @@ def test_error_X_beta_len_distinct(self): """ Test that the number of beta bases and explanatory variables are not different """ - x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + x_fd = FDataBasis(Monomial(n_basis=7), np.identity(7)) y = [1 for _ in range(7)] - beta = Fourier(nbasis=5) + beta = Fourier(n_basis=5) scalar = LinearScalarRegression([beta]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd, x_fd], y) @@ -111,16 +112,16 @@ def test_error_y_X_samples_different(self): """ Test that the number of response samples and explanatory samples are not different """ - x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + x_fd = FDataBasis(Monomial(n_basis=7), np.identity(7)) y = [1 for _ in range(8)] - beta = Fourier(nbasis=5) + beta = Fourier(n_basis=5) scalar = LinearScalarRegression([beta]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) - x_fd = FDataBasis(Monomial(nbasis=8), np.identity(8)) + x_fd = FDataBasis(Monomial(n_basis=8), np.identity(8)) y = [1 for _ in range(7)] - beta = Fourier(nbasis=5) + beta = Fourier(n_basis=5) scalar = LinearScalarRegression([beta]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) @@ -128,9 +129,9 @@ def test_error_y_X_samples_different(self): def test_error_beta_not_basis(self): """ Test that all beta are Basis objects. """ - x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + x_fd = FDataBasis(Monomial(n_basis=7), np.identity(7)) y = [1 for _ in range(7)] - beta = FDataBasis(Monomial(nbasis=7), np.identity(7)) + beta = FDataBasis(Monomial(n_basis=7), np.identity(7)) scalar = LinearScalarRegression([beta]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y) @@ -139,10 +140,10 @@ def test_error_weights_lenght(self): """ Test that the number of weights is equal to the number of samples """ - x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + x_fd = FDataBasis(Monomial(n_basis=7), np.identity(7)) y = [1 for _ in range(7)] weights = [1 for _ in range(8)] - beta = Monomial(nbasis=7) + beta = Monomial(n_basis=7) scalar = LinearScalarRegression([beta]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y, weights) @@ -150,10 +151,10 @@ def test_error_weights_lenght(self): def test_error_weights_negative(self): """ Test that none of the weights are negative. """ - x_fd = FDataBasis(Monomial(nbasis=7), np.identity(7)) + x_fd = FDataBasis(Monomial(n_basis=7), np.identity(7)) y = [1 for _ in range(7)] weights = [-1 for _ in range(7)] - beta = Monomial(nbasis=7) + beta = Monomial(n_basis=7) scalar = LinearScalarRegression([beta]) np.testing.assert_raises(ValueError, scalar.fit, [x_fd], y, weights) diff --git a/tests/test_smoothing.py b/tests/test_smoothing.py index 2db7721a0..097929afb 100644 --- a/tests/test_smoothing.py +++ b/tests/test_smoothing.py @@ -75,7 +75,7 @@ class TestBasisSmoother(unittest.TestCase): def test_cholesky(self): t = np.linspace(0, 1, 5) x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) - basis = BSpline((0, 1), nbasis=5) + basis = BSpline((0, 1), n_basis=5) fd = FDataGrid(data_matrix=x, sample_points=t) smoother = smoothing.BasisSmoother(basis=basis, smoothing_parameter=10, @@ -90,7 +90,7 @@ def test_cholesky(self): def test_qr(self): t = np.linspace(0, 1, 5) x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) - basis = BSpline((0, 1), nbasis=5) + basis = BSpline((0, 1), n_basis=5) fd = FDataGrid(data_matrix=x, sample_points=t) smoother = smoothing.BasisSmoother(basis=basis, smoothing_parameter=10, @@ -107,7 +107,7 @@ def test_monomial_smoothing(self): # where the fit is very good but its just for testing purposes t = np.linspace(0, 1, 5) x = np.sin(2 * np.pi * t) + np.cos(2 * np.pi * t) - basis = Monomial(nbasis=4) + basis = Monomial(n_basis=4) fd = FDataGrid(data_matrix=x, sample_points=t) smoother = smoothing.BasisSmoother(basis=basis, smoothing_parameter=1, From a664c03a08f29fbe013d6cd4466bdf64ee3a874b Mon Sep 17 00:00:00 2001 From: vnmabus Date: Thu, 12 Sep 2019 15:46:08 +0200 Subject: [PATCH 214/222] Rename clustering_plots to clustering. --- docs/modules/exploratory/visualization.rst | 2 +- .../visualization/{clustering_plots.rst => clustering.rst} | 6 +++--- examples/plot_clustering.py | 2 +- skfda/exploratory/visualization/__init__.py | 2 +- .../visualization/{clustering_plots.py => clustering.py} | 0 5 files changed, 6 insertions(+), 6 deletions(-) rename docs/modules/exploratory/visualization/{clustering_plots.rst => clustering.rst} (77%) rename skfda/exploratory/visualization/{clustering_plots.py => clustering.py} (100%) diff --git a/docs/modules/exploratory/visualization.rst b/docs/modules/exploratory/visualization.rst index 8eb137c4b..cb701b337 100644 --- a/docs/modules/exploratory/visualization.rst +++ b/docs/modules/exploratory/visualization.rst @@ -10,4 +10,4 @@ the functional data, that highlight several important aspects of it. visualization/boxplot visualization/magnitude_shape_plot - visualization/clustering_plots \ No newline at end of file + visualization/clustering \ No newline at end of file diff --git a/docs/modules/exploratory/visualization/clustering_plots.rst b/docs/modules/exploratory/visualization/clustering.rst similarity index 77% rename from docs/modules/exploratory/visualization/clustering_plots.rst rename to docs/modules/exploratory/visualization/clustering.rst index 26d175a1f..86848f9ae 100644 --- a/docs/modules/exploratory/visualization/clustering_plots.rst +++ b/docs/modules/exploratory/visualization/clustering.rst @@ -7,9 +7,9 @@ implemented. It contains the following methods: .. autosummary:: :toctree: autosummary - skfda.exploratory.visualization.clustering_plots.plot_clusters - skfda.exploratory.visualization.clustering_plots.plot_cluster_lines - skfda.exploratory.visualization.clustering_plots.plot_cluster_bars + skfda.exploratory.visualization.clustering.plot_clusters + skfda.exploratory.visualization.clustering.plot_cluster_lines + skfda.exploratory.visualization.clustering.plot_cluster_bars In the first one, the samples of the FDataGrid are divided by clusters which are assigned different colors. The following functions, are only valid for the diff --git a/examples/plot_clustering.py b/examples/plot_clustering.py index b5e0f5e61..537c1e264 100644 --- a/examples/plot_clustering.py +++ b/examples/plot_clustering.py @@ -15,7 +15,7 @@ import matplotlib.pyplot as plt import numpy as np from skfda import datasets -from skfda.exploratory.visualization.clustering_plots import ( +from skfda.exploratory.visualization.clustering import ( plot_clusters, plot_cluster_lines, plot_cluster_bars) from skfda.ml.clustering.base_kmeans import KMeans, FuzzyKMeans diff --git a/skfda/exploratory/visualization/__init__.py b/skfda/exploratory/visualization/__init__.py index 7d5f553cf..86d20e2d4 100644 --- a/skfda/exploratory/visualization/__init__.py +++ b/skfda/exploratory/visualization/__init__.py @@ -1,3 +1,3 @@ +from . import clustering from ._boxplot import Boxplot, SurfaceBoxplot from ._magnitude_shape_plot import MagnitudeShapePlot -from . import clustering_plots diff --git a/skfda/exploratory/visualization/clustering_plots.py b/skfda/exploratory/visualization/clustering.py similarity index 100% rename from skfda/exploratory/visualization/clustering_plots.py rename to skfda/exploratory/visualization/clustering.py From 6c3b53a6b7fc85ea2848cb0b0973e8ab7a3e2632 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 13 Sep 2019 12:43:35 +0200 Subject: [PATCH 215/222] First version of plot separated from FData. --- skfda/exploratory/visualization/__init__.py | 2 +- .../visualization/representation.py | 344 ++++++++++++++++++ skfda/representation/_functional_data.py | 132 +------ 3 files changed, 351 insertions(+), 127 deletions(-) create mode 100644 skfda/exploratory/visualization/representation.py diff --git a/skfda/exploratory/visualization/__init__.py b/skfda/exploratory/visualization/__init__.py index 86d20e2d4..8f135ae5f 100644 --- a/skfda/exploratory/visualization/__init__.py +++ b/skfda/exploratory/visualization/__init__.py @@ -1,3 +1,3 @@ -from . import clustering +from . import clustering, representation from ._boxplot import Boxplot, SurfaceBoxplot from ._magnitude_shape_plot import MagnitudeShapePlot diff --git a/skfda/exploratory/visualization/representation.py b/skfda/exploratory/visualization/representation.py new file mode 100644 index 000000000..3371012bf --- /dev/null +++ b/skfda/exploratory/visualization/representation.py @@ -0,0 +1,344 @@ +import math + +import matplotlib.axes +import matplotlib.cm +import matplotlib.figure +import matplotlib.patches + +import numpy as np + +from ..._utils import _create_figure, _list_of_arrays, constants + + +def _get_figure_and_axes(chart=None, fig=None, axes=None): + """Obtain the figure and axes from the arguments.""" + + num_defined = sum(e is not None for e in (chart, fig, axes)) + if num_defined > 1: + raise ValueError("Only one of chart, fig and axes parameters" + "can be passed as an argument.") + + # Parse chart argument + if chart is not None: + if isinstance(chart, matplotlib.figure.Figure): + fig = chart + else: + axes = chart + + if fig is None and axes is None: + fig = fig = _create_figure() + axes = [] + + elif fig is not None: + axes = fig.axes + + else: + if isinstance(axes, matplotlib.axes.Axes): + axes = [axes] + + fig = axes[0].figure + + return fig, axes + + +def _get_axes_shape(fdata, n_rows=None, n_cols=None): + """Get the number of rows and columns of the subplots""" + + if ((n_rows is not None and n_cols is not None) + and ((n_rows * n_cols) < fdata.dim_codomain)): + raise ValueError(f"The number of rows ({n_rows}) multiplied by " + f"the number of columns ({n_cols}) " + f"is less than the dimension of " + f"the image ({fdata.dim_codomain})") + + if n_rows is None and n_cols is None: + n_cols = int(math.ceil(math.sqrt(fdata.dim_codomain))) + n_rows = int(math.ceil(fdata.dim_codomain / n_cols)) + elif n_rows is None and n_cols is not None: + n_rows = int(math.ceil(fdata.dim_codomain / n_cols)) + elif n_cols is None and n_rows is not None: + n_cols = int(math.ceil(fdata.dim_codomain / n_rows)) + + return n_rows, n_cols + + +def _set_figure_layout_for_fdata(fdata, fig=None, axes=None, + n_rows=None, n_cols=None): + """Set the figure axes for plotting a + :class:`~skfda.representation.FData` object. + + Args: + fdata (FData): functional data object. + fig (figure object): figure over with the graphs are + plotted in case ax is not specified. + ax (list of axis objects): axis over where the graphs are + plotted. + n_rows (int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Can only be passed + if no axes are specified. + n_cols (int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Can only be + passed if no axes are specified. + + Returns: + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * axes (list): axes in which the graphs are plotted. + + """ + if fdata.dim_domain > 2: + raise NotImplementedError("Plot only supported for functional data" + " modeled in at most 3 dimensions.") + + if len(axes) != 0 and len(axes) != fdata.dim_codomain: + raise ValueError("The number of axes must be 0 (to create them) or " + "equal to the dimension of the image.") + + if len(axes) != 0 and (n_rows is not None or n_cols is not None): + raise ValueError("The number of columns and/or number of rows of " + "the figure, in which each dimension of the " + "image is plotted, can only be customized in case " + "that no axes are provided.") + + if fdata.dim_domain == 1: + projection = 'rectilinear' + else: + projection = '3d' + + if len(axes) == 0: + # Create the axes + + n_rows, n_cols = _get_axes_shape(fdata, n_rows, n_cols) + fig.subplots(nrows=n_rows, ncols=n_cols, + subplot_kw={"projection": projection}) + axes = fig.axes + + else: + # Check that the projections are right + + if not all(a.name == projection for a in axes): + raise ValueError(f"The projection of the axes should be " + f"{projection}") + + return fig, axes + + +def _get_label_colors(n_labels, label_colors=None): + """Get the colors of each label""" + + if label_colors is not None: + if len(label_colors) != n_labels: + raise ValueError("There must be a color in label_colors " + "for each of the labels that appear in " + "sample_labels.") + else: + colormap = matplotlib.cm.get_cmap() + label_colors = colormap(np.arange(n_labels) / (n_labels - 1)) + + return label_colors + + +def _set_labels(fdata, fig=None, axes=None, patches=None): + """Set labels if any. + + Args: + fdata (FData): functional data object. + fig (figure object): figure object containing the axes that + implement set_xlabel and set_ylabel, and set_zlabel in case + of a 3d projection. + axes (list of axes): axes objects that implement set_xlabel and + set_ylabel, and set_zlabel in case of a 3d projection; used if + fig is None. + patches (list of mpatches.Patch); objects used to generate each + entry in the legend. + + """ + + # Dataset name + if fdata.dataset_label is not None: + fig.suptitle(fdata.dataset_label) + + # Legend + if patches is not None and fdata.dim_codomain > 1: + fig.legend(handles=patches) + elif patches is not None: + axes[0].legend(handles=patches) + + # Axis labels + if fdata.axes_labels is not None: + if axes[0].name == '3d': + for i in range(fdata.dim_codomain): + if fdata.axes_labels[0] is not None: + axes[i].set_xlabel(fdata.axes_labels[0]) + if fdata.axes_labels[1] is not None: + axes[i].set_ylabel(fdata.axes_labels[1]) + if fdata.axes_labels[i + 2] is not None: + axes[i].set_zlabel(fdata.axes_labels[i + 2]) + else: + for i in range(fdata.dim_codomain): + if fdata.axes_labels[0] is not None: + axes[i].set_xlabel(fdata.axes_labels[0]) + if fdata.axes_labels[i + 1] is not None: + axes[i].set_ylabel(fdata.axes_labels[i + 1]) + + +def plot_curves(fdata, chart=None, *, derivative=0, fig=None, axes=None, + n_rows=None, n_cols=None, n_points=None, domain_range=None, + sample_labels=None, label_colors=None, label_names=None, + **kwargs): + """Plot the FDatGrid object. + + Args: + chart (figure object, axe or list of axes, optional): figure over + with the graphs are plotted or axis over where the graphs are + plotted. If None and ax is also None, the figure is + initialized. + derivative (int or tuple, optional): Order of derivative to be + plotted. In case of surfaces a tuple with the order of + derivation in each direction can be passed. See + :func:`evaluate` to obtain more information. Defaults 0. + fig (figure object, optional): figure over with the graphs are + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. + axes (list of axis objects, optional): axis over where the graphs are + plotted. If None, see param fig. + n_rows (int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + n_cols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. + n_points (int or tuple, optional): Number of points to evaluate in + the plot. In case of surfaces a tuple of length 2 can be pased + with the number of points to plot in each axis, otherwise the + same number of points will be used in the two axes. By default + in unidimensional plots will be used 501 points; in surfaces + will be used 30 points per axis, wich makes a grid with 900 + points. + domain_range (tuple or list of tuples, optional): Range where the + function will be plotted. In objects with unidimensional domain + the domain range should be a tuple with the bounds of the + interval; in the case of surfaces a list with 2 tuples with + the ranges for each dimension. Default uses the domain range + of the functional object. + sample_labels (list of int): contains integers from [0 to number of + labels) indicating to which group each sample belongs to. Then, + the samples with the same label are plotted in the same color. + If None, the default value, each sample is plotted in the color + assigned by matplotlib.pyplot.rcParams['axes.prop_cycle']. + label_colors (list of colors): colors in which groups are + represented, there must be one for each group. If None, each + group is shown with distict colors in the "Greys" colormap. + label_names (list of str): name of each of the groups which appear + in a legend, there must be one for each one. Defaults to None + and the legend is not shown. + **kwargs: if dim_domain is 1, keyword arguments to be passed to + the matplotlib.pyplot.plot function; if dim_domain is 2, + keyword arguments to be passed to the + matplotlib.pyplot.plot_surface function. + + Returns: + fig (figure object): figure object in which the graphs are plotted. + + """ + + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout_for_fdata(fdata, fig, axes, n_rows, n_cols) + + if domain_range is None: + domain_range = fdata.domain_range + else: + domain_range = _list_of_arrays(domain_range) + + patches = None + next_color = False + + if sample_labels is not None: + # In this case, each curve has a label, and all curves with the same + # label should have the same color + + sample_labels = np.asarray(sample_labels) + + n_labels = np.max(sample_labels) + 1 + + if np.any((sample_labels < 0) | (sample_labels >= n_labels)) or \ + not np.all(np.isin(range(n_labels), sample_labels)): + raise ValueError("Sample_labels must contain at least an " + "occurence of numbers between 0 and number " + "of distint sample labels.") + + label_colors = _get_label_colors(n_labels, label_colors) + sample_colors = np.asarray(label_colors)[sample_labels] + + if label_names is not None: + if len(label_names) != n_labels: + raise ValueError("There must be a name in label_names " + "for each of the labels that appear in " + "sample_labels.") + + patches = [matplotlib.patches.Patch(color=c, label=l) + for c, l in zip(label_colors, label_names)] + + else: + # In this case, each curve has a different color unless specified + # otherwise + + if 'color' in kwargs: + sample_colors = fdata.n_samples * [kwargs.get("color")] + kwargs.pop('color') + + elif 'c' in kwargs: + sample_colors = fdata.n_samples * [kwargs.get("color")] + kwargs.pop('c') + + else: + sample_colors = np.empty((fdata.n_samples,)).astype(str) + next_color = True + + if fdata.dim_domain == 1: + + if n_points is None: + n_points = constants.N_POINTS_UNIDIMENSIONAL_PLOT_MESH + + # Evaluates the object in a linspace + eval_points = np.linspace(*domain_range[0], n_points) + mat = fdata(eval_points, derivative=derivative, keepdims=True) + + for i in range(fdata.dim_codomain): + for j in range(fdata.n_samples): + if sample_labels is None and next_color: + sample_colors[j] = axes[i]._get_lines.get_next_color() + axes[i].plot(eval_points, mat[j, ..., i].T, + c=sample_colors[j], **kwargs) + + else: + + # Selects the number of points + if n_points is None: + npoints = 2 * (constants.N_POINTS_SURFACE_PLOT_AX,) + elif np.isscalar(npoints): + npoints = (npoints, npoints) + elif len(npoints) != 2: + raise ValueError(f"n_points should be a number or a tuple of " + f"length 2, and has length {len(npoints)}") + + # Axes where will be evaluated + x = np.linspace(*domain_range[0], npoints[0]) + y = np.linspace(*domain_range[1], npoints[1]) + + # Evaluation of the functional object + Z = fdata((x, y), derivative=derivative, grid=True, keepdims=True) + + X, Y = np.meshgrid(x, y, indexing='ij') + + for i in range(fdata.dim_codomain): + for j in range(fdata.n_samples): + if sample_labels is None and next_color: + sample_colors[j] = axes[i]._get_lines.get_next_color() + axes[i].plot_surface(X, Y, Z[j, ..., i], + color=sample_colors[j], **kwargs) + + _set_labels(fdata, fig, axes, patches) + + return fig diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 8201ff20a..d7486b8a0 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -832,9 +832,7 @@ def generic_plotting_checks(self, fig=None, ax=None, nrows=None, return fig, ax - def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, - ncols=None, npoints=None, domain_range=None, sample_labels=None, - label_colors=None, label_names=None, **kwargs): + def plot(self, *args, **kwargs): """Plot the FDatGrid object. Args: @@ -851,13 +849,13 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, None, the figure is initialized. ax (list of axis objects, optional): axis over where the graphs are plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure + n_rows (int, optional): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int, optional): designates the number of columns of the + n_cols (int, optional): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - npoints (int or tuple, optional): Number of points to evaluate in + n_points (int or tuple, optional): Number of points to evaluate in the plot. In case of surfaces a tuple of length 2 can be pased with the number of points to plot in each axis, otherwise the same number of points will be used in the two axes. By default @@ -890,127 +888,9 @@ def plot(self, chart=None, *, derivative=0, fig=None, ax=None, nrows=None, fig (figure object): figure object in which the graphs are plotted. """ + from ..exploratory.visualization.representation import plot_curves - # Parse chart argument - if chart is not None: - if fig is not None or ax is not None: - raise ValueError("fig, axes and chart parameters cannot " - "be passed as arguments at the same time.") - if isinstance(chart, plt.Figure): - fig = chart - elif isinstance(chart, Axes): - ax = [chart] - else: - ax = chart - - if domain_range is None: - domain_range = self.domain_range - else: - domain_range = _list_of_arrays(domain_range) - - fig, ax = self.generic_plotting_checks(fig, ax, nrows, ncols) - - patches = None - next_color = False - - if sample_labels is not None: - sample_labels = np.asarray(sample_labels) - - nlabels = np.max(sample_labels) + 1 - - if np.any((sample_labels < 0) | (sample_labels >= nlabels)) or \ - not np.all(np.isin(range(nlabels), sample_labels)): - raise ValueError("sample_labels must contain at least an " - "occurence of numbers between 0 and number " - "of distint sample labels.") - - if label_colors is not None: - if len(label_colors) != nlabels: - raise ValueError("There must be a color in label_colors " - "for each of the labels that appear in " - "sample_labels.") - sample_colors = np.asarray(label_colors)[sample_labels] - - else: - colormap = plt.cm.get_cmap('Greys') - sample_colors = colormap(sample_labels / (nlabels - 1)) - - if label_names is not None: - if len(label_names) != nlabels: - raise ValueError("There must be a name in label_names " - "for each of the labels that appear in " - "sample_labels.") - - if label_colors is None: - label_colors = colormap( - np.arange(nlabels) / (nlabels - 1)) - - patches = [] - for i in range(nlabels): - patches.append( - mpatches.Patch(color=label_colors[i], - label=label_names[i])) - - else: - - if 'color' in kwargs: - sample_colors = self.n_samples * [kwargs.get("color")] - kwargs.pop('color') - - elif 'c' in kwargs: - sample_colors = self.n_samples * [kwargs.get("color")] - kwargs.pop('c') - - else: - sample_colors = np.empty((self.n_samples,)).astype(str) - next_color = True - - if self.dim_domain == 1: - - if npoints is None: - npoints = constants.N_POINTS_UNIDIMENSIONAL_PLOT_MESH - - # Evaluates the object in a linspace - eval_points = np.linspace(*domain_range[0], npoints) - mat = self(eval_points, derivative=derivative, keepdims=True) - - for i in range(self.dim_codomain): - for j in range(self.n_samples): - if sample_labels is None and next_color: - sample_colors[j] = ax[i]._get_lines.get_next_color() - ax[i].plot(eval_points, mat[j, ..., i].T, - c=sample_colors[j], **kwargs) - - else: - - # Selects the number of points - if npoints is None: - npoints = 2 * (constants.N_POINTS_SURFACE_PLOT_AX,) - elif np.isscalar(npoints): - npoints = (npoints, npoints) - elif len(npoints) != 2: - raise ValueError("npoints should be a number or a tuple of " - "length 2.") - - # Axes where will be evaluated - x = np.linspace(*domain_range[0], npoints[0]) - y = np.linspace(*domain_range[1], npoints[1]) - - # Evaluation of the functional object - Z = self((x, y), derivative=derivative, grid=True, keepdims=True) - - X, Y = np.meshgrid(x, y, indexing='ij') - - for i in range(self.dim_codomain): - for j in range(self.n_samples): - if sample_labels is None and next_color: - sample_colors[j] = ax[i]._get_lines.get_next_color() - ax[i].plot_surface(X, Y, Z[j, ..., i], - color=sample_colors[j], **kwargs) - - self.set_labels(fig, ax, patches) - - return fig + return plot_curves(self, *args, **kwargs) @abstractmethod def copy(self, **kwargs): From f40789771f8844cd4aa89d24d9ffe6fa4ae5185f Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 13 Sep 2019 13:34:37 +0200 Subject: [PATCH 216/222] Extracted plotting functions. --- .../visualization/representation.py | 245 ++++++++++++++---- skfda/representation/_functional_data.py | 192 +------------- skfda/representation/grid.py | 28 +- 3 files changed, 201 insertions(+), 264 deletions(-) diff --git a/skfda/exploratory/visualization/representation.py b/skfda/exploratory/visualization/representation.py index 3371012bf..1e97860ca 100644 --- a/skfda/exploratory/visualization/representation.py +++ b/skfda/exploratory/visualization/representation.py @@ -139,6 +139,54 @@ def _get_label_colors(n_labels, label_colors=None): return label_colors +def _get_color_info(fdata, sample_labels, label_names, label_colors, kwargs): + + patches = None + + if sample_labels is not None: + # In this case, each curve has a label, and all curves with the same + # label should have the same color + + sample_labels = np.asarray(sample_labels) + + n_labels = np.max(sample_labels) + 1 + + if np.any((sample_labels < 0) | (sample_labels >= n_labels)) or \ + not np.all(np.isin(range(n_labels), sample_labels)): + raise ValueError("Sample_labels must contain at least an " + "occurence of numbers between 0 and number " + "of distint sample labels.") + + label_colors = _get_label_colors(n_labels, label_colors) + sample_colors = np.asarray(label_colors)[sample_labels] + + if label_names is not None: + if len(label_names) != n_labels: + raise ValueError("There must be a name in label_names " + "for each of the labels that appear in " + "sample_labels.") + + patches = [matplotlib.patches.Patch(color=c, label=l) + for c, l in zip(label_colors, label_names)] + + else: + # In this case, each curve has a different color unless specified + # otherwise + + if 'color' in kwargs: + sample_colors = fdata.n_samples * [kwargs.get("color")] + kwargs.pop('color') + + elif 'c' in kwargs: + sample_colors = fdata.n_samples * [kwargs.get("color")] + kwargs.pop('c') + + else: + sample_colors = None + + return sample_colors, patches + + def _set_labels(fdata, fig=None, axes=None, patches=None): """Set labels if any. @@ -183,11 +231,15 @@ def _set_labels(fdata, fig=None, axes=None, patches=None): axes[i].set_ylabel(fdata.axes_labels[i + 1]) -def plot_curves(fdata, chart=None, *, derivative=0, fig=None, axes=None, - n_rows=None, n_cols=None, n_points=None, domain_range=None, - sample_labels=None, label_colors=None, label_names=None, - **kwargs): - """Plot the FDatGrid object. +def plot_hypersurfaces(fdata, chart=None, *, derivative=0, fig=None, axes=None, + n_rows=None, n_cols=None, n_points=None, + domain_range=None, + sample_labels=None, label_colors=None, label_names=None, + **kwargs): + """Plot the FDatGrid object as hypersurfaces. + + Plots each coordinate separately. If the domain is one dimensional, the + plots will be curves, and if it is two dimensional, they will be surfaces. Args: chart (figure object, axe or list of axes, optional): figure over @@ -251,50 +303,8 @@ def plot_curves(fdata, chart=None, *, derivative=0, fig=None, axes=None, else: domain_range = _list_of_arrays(domain_range) - patches = None - next_color = False - - if sample_labels is not None: - # In this case, each curve has a label, and all curves with the same - # label should have the same color - - sample_labels = np.asarray(sample_labels) - - n_labels = np.max(sample_labels) + 1 - - if np.any((sample_labels < 0) | (sample_labels >= n_labels)) or \ - not np.all(np.isin(range(n_labels), sample_labels)): - raise ValueError("Sample_labels must contain at least an " - "occurence of numbers between 0 and number " - "of distint sample labels.") - - label_colors = _get_label_colors(n_labels, label_colors) - sample_colors = np.asarray(label_colors)[sample_labels] - - if label_names is not None: - if len(label_names) != n_labels: - raise ValueError("There must be a name in label_names " - "for each of the labels that appear in " - "sample_labels.") - - patches = [matplotlib.patches.Patch(color=c, label=l) - for c, l in zip(label_colors, label_names)] - - else: - # In this case, each curve has a different color unless specified - # otherwise - - if 'color' in kwargs: - sample_colors = fdata.n_samples * [kwargs.get("color")] - kwargs.pop('color') - - elif 'c' in kwargs: - sample_colors = fdata.n_samples * [kwargs.get("color")] - kwargs.pop('c') - - else: - sample_colors = np.empty((fdata.n_samples,)).astype(str) - next_color = True + sample_colors, patches = _get_color_info( + fdata, sample_labels, label_names, label_colors, kwargs) if fdata.dim_domain == 1: @@ -305,12 +315,16 @@ def plot_curves(fdata, chart=None, *, derivative=0, fig=None, axes=None, eval_points = np.linspace(*domain_range[0], n_points) mat = fdata(eval_points, derivative=derivative, keepdims=True) + color_dict = {} + for i in range(fdata.dim_codomain): for j in range(fdata.n_samples): - if sample_labels is None and next_color: - sample_colors[j] = axes[i]._get_lines.get_next_color() + + if sample_labels is not None: + color_dict["color"] = sample_colors[j] + axes[i].plot(eval_points, mat[j, ..., i].T, - c=sample_colors[j], **kwargs) + **color_dict, **kwargs) else: @@ -332,12 +346,133 @@ def plot_curves(fdata, chart=None, *, derivative=0, fig=None, axes=None, X, Y = np.meshgrid(x, y, indexing='ij') + color_dict = {} + for i in range(fdata.dim_codomain): for j in range(fdata.n_samples): - if sample_labels is None and next_color: - sample_colors[j] = axes[i]._get_lines.get_next_color() + + if sample_labels is not None: + color_dict["color"] = sample_colors[j] + axes[i].plot_surface(X, Y, Z[j, ..., i], - color=sample_colors[j], **kwargs) + **color_dict, **kwargs) + + _set_labels(fdata, fig, axes, patches) + + return fig + + +def plot_scatter(fdata, chart=None, *, sample_points=None, derivative=0, + fig=None, axes=None, + n_rows=None, n_cols=None, n_points=None, domain_range=None, + sample_labels=None, label_colors=None, label_names=None, + **kwargs): + """Plot the FDatGrid object. + + Args: + chart (figure object, axe or list of axes, optional): figure over + with the graphs are plotted or axis over where the graphs are + plotted. If None and ax is also None, the figure is + initialized. + sample_points (ndarray): points to plot. + derivative (int or tuple, optional): Order of derivative to be + plotted. In case of surfaces a tuple with the order of + derivation in each direction can be passed. See + :func:`evaluate` to obtain more information. Defaults 0. + fig (figure object, optional): figure over with the graphs are + plotted in case ax is not specified. If None and ax is also + None, the figure is initialized. + axes (list of axis objects, optional): axis over where the graphs are + plotted. If None, see param fig. + n_rows (int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Only specified + if fig and ax are None. + n_cols(int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Only + specified if fig and ax are None. + n_points (int or tuple, optional): Number of points to evaluate in + the plot. In case of surfaces a tuple of length 2 can be pased + with the number of points to plot in each axis, otherwise the + same number of points will be used in the two axes. By default + in unidimensional plots will be used 501 points; in surfaces + will be used 30 points per axis, wich makes a grid with 900 + points. + domain_range (tuple or list of tuples, optional): Range where the + function will be plotted. In objects with unidimensional domain + the domain range should be a tuple with the bounds of the + interval; in the case of surfaces a list with 2 tuples with + the ranges for each dimension. Default uses the domain range + of the functional object. + sample_labels (list of int): contains integers from [0 to number of + labels) indicating to which group each sample belongs to. Then, + the samples with the same label are plotted in the same color. + If None, the default value, each sample is plotted in the color + assigned by matplotlib.pyplot.rcParams['axes.prop_cycle']. + label_colors (list of colors): colors in which groups are + represented, there must be one for each group. If None, each + group is shown with distict colors in the "Greys" colormap. + label_names (list of str): name of each of the groups which appear + in a legend, there must be one for each one. Defaults to None + and the legend is not shown. + **kwargs: if dim_domain is 1, keyword arguments to be passed to + the matplotlib.pyplot.plot function; if dim_domain is 2, + keyword arguments to be passed to the + matplotlib.pyplot.plot_surface function. + + Returns: + fig (figure object): figure object in which the graphs are plotted. + + """ + + if sample_points is None: + # This can only be done for FDataGrid + sample_points = fdata.sample_points + evaluated_points = fdata.data_matrix + else: + evaluated_points = fdata(sample_points, grid=True) + + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout_for_fdata(fdata, fig, axes, n_rows, n_cols) + + if domain_range is None: + domain_range = fdata.domain_range + else: + domain_range = _list_of_arrays(domain_range) + + sample_colors, patches = _get_color_info( + fdata, sample_labels, label_names, label_colors, kwargs) + + if fdata.dim_domain == 1: + + color_dict = {} + + for i in range(fdata.dim_codomain): + for j in range(fdata.n_samples): + + if sample_labels is not None: + color_dict["color"] = sample_colors[j] + + axes[i].scatter(sample_points[0], + evaluated_points[j, ..., i].T, + **color_dict, **kwargs) + + else: + + X = fdata.sample_points[0] + Y = fdata.sample_points[1] + X, Y = np.meshgrid(X, Y) + + color_dict = {} + + for i in range(fdata.dim_codomain): + for j in range(fdata.n_samples): + + if sample_labels is not None: + color_dict["color"] = sample_colors[j] + + axes[i].scatter(X, Y, + evaluated_points[j, ..., i].T, + **color_dict, **kwargs) _set_labels(fdata, fig, axes, patches) diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index d7486b8a0..4f66343d8 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -4,17 +4,12 @@ objects of the package and contains some commons methods. """ -from abc import ABC, abstractmethod, abstractproperty +from abc import ABC, abstractmethod -from matplotlib.axes import Axes -import mpl_toolkits.mplot3d import pandas.api.extensions - -import matplotlib.patches as mpatches -import matplotlib.pyplot as plt import numpy as np -from .._utils import _coordinate_list, _list_of_arrays, constants, _create_figure +from .._utils import _coordinate_list, _list_of_arrays from .extrapolation import _parse_extrapolation @@ -602,74 +597,6 @@ def shift(self, shifts, *, restrict_domain=False, extrapolation=None, """ pass - def set_figure_and_axes(self, nrows, ncols): - """Set figure and its axes. - - Args: - nrows(int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. ncols must be - also be customized in the same call. - ncols(int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. nrows - must be also be customized in the same call. - - Returns: - fig (figure object): figure object initialiazed. - ax (axes object): axes of the initialized figure. - - """ - - if self.dim_domain == 1: - projection = None - else: - projection = '3d' - - if ncols is None and nrows is None: - ncols = int(np.ceil(np.sqrt(self.dim_codomain))) - nrows = int(np.ceil(self.dim_codomain / ncols)) - elif ncols is None and nrows is not None: - nrows = int(np.ceil(self.dim_codomain / nrows)) - elif ncols is not None and nrows is None: - nrows = int(np.ceil(self.dim_codomain / ncols)) - - fig = _create_figure() - axes = fig.get_axes() - - # If it is not empty - if len(axes) != 0: - # Gets geometry of current fig - geometry = (fig.axes[0] - .get_subplotspec() - .get_topmost_subplotspec() - .get_gridspec().get_geometry()) - - # Check if the projection of the axes is the same - if projection == '3d': - same_projection = all(a.name == '3d' for a in axes) - else: - same_projection = all(a.name == 'rectilinear' for a in axes) - - # If compatible uses the same figure - if (same_projection and geometry == (nrows, ncols) and - self.dim_codomain == len(axes)): - return fig, axes - - else: # Create new figure if it is not compatible - fig = plt.figure() - - for i in range(self.dim_codomain): - fig.add_subplot(nrows, ncols, i + 1, projection=projection) - - if ncols > 1 and self.axes_labels is not None and self.dim_codomain > 1: - plt.subplots_adjust(wspace=0.4) - - if nrows > 1 and self.axes_labels is not None and self.dim_codomain > 1: - plt.subplots_adjust(hspace=0.4) - - ax = fig.get_axes() - - return fig, ax - def _get_labels_coordinates(self, key): """Return the labels of a function when it is indexed by its components. @@ -722,116 +649,6 @@ def _join_labels_coordinates(self, *others): return labels - def set_labels(self, fig=None, ax=None, patches=None): - """Set labels if any. - - Args: - fig (figure object): figure object containing the axes that - implement set_xlabel and set_ylabel, and set_zlabel in case - of a 3d projection. - ax (list of axes): axes objects that implement set_xlabel and - set_ylabel, and set_zlabel in case of a 3d projection; used if - fig is None. - patches (list of mpatches.Patch); objects used to generate each - entry in the legend. - - """ - if fig is not None: - if self.dataset_label is not None: - fig.suptitle(self.dataset_label) - ax = fig.get_axes() - if patches is not None and self.dim_codomain > 1: - fig.legend(handles=patches) - elif patches is not None: - ax[0].legend(handles=patches) - elif len(ax) == 1: - if self.dataset_label is not None: - ax[0].set_title(self.dataset_label) - if patches is not None: - ax[0].legend(handles=patches) - - if self.axes_labels is not None: - if ax[0].name == '3d': - for i in range(self.dim_codomain): - if self.axes_labels[0] is not None: - ax[i].set_xlabel(self.axes_labels[0]) - if self.axes_labels[1] is not None: - ax[i].set_ylabel(self.axes_labels[1]) - if self.axes_labels[i + 2] is not None: - ax[i].set_zlabel(self.axes_labels[i + 2]) - else: - for i in range(self.dim_codomain): - if self.axes_labels[0] is not None: - ax[i].set_xlabel(self.axes_labels[0]) - if self.axes_labels[i + 1] is not None: - ax[i].set_ylabel(self.axes_labels[i + 1]) - - def generic_plotting_checks(self, fig=None, ax=None, nrows=None, - ncols=None): - """Check the arguments passed to both :func:`plot ` - and :func:`scatter ` methods. - - Args: - fig (figure object, optional): figure over with the graphs are - plotted in case ax is not specified. If None and ax is also - None, the figure is initialized. - ax (list of axis objects, optional): axis over where the graphs are - plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. Only specified - if fig and ax are None. ncols must be also be customized in the - same call. - ncols(int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. Only - specified if fig and ax are None. nrows must be also be - customized in the same call. - - Returns: - (tuple): tuple containing: - - * fig (figure): figure object in which the graphs are plotted. - * ax (list): axes in which the graphs are plotted. - - """ - if self.dim_domain > 2: - raise NotImplementedError("Plot only supported for functional data" - " modeled in at most 3 dimensions.") - - if fig is not None and ax is not None: - raise ValueError("fig and axes parameters cannot be passed as " - "arguments at the same time.") - - if fig is not None and len(fig.get_axes()) != self.dim_codomain: - raise ValueError("Number of axes of the figure must be equal to " - "the dimension of the image.") - - if ax is not None and len(ax) != self.dim_codomain: - raise ValueError("Number of axes must be equal to the dimension " - "of the image.") - - if ((ax is not None or fig is not None) and - (nrows is not None or ncols is not None)): - raise ValueError("The number of columns and/or number of rows of " - "the figure, in which each dimension of the " - "image is plotted, can only be customized in case" - " fig is None and ax is None.") - - if ((nrows is not None and ncols is not None) - and ((nrows * ncols) < self.dim_codomain)): - raise ValueError("The number of columns and the number of rows " - "specified is incorrect.") - - if fig is None and ax is None: - fig, ax = self.set_figure_and_axes(nrows, ncols) - - elif fig is not None: - ax = fig.axes - - else: - fig = ax[0].get_figure() - - return fig, ax - def plot(self, *args, **kwargs): """Plot the FDatGrid object. @@ -888,9 +705,10 @@ def plot(self, *args, **kwargs): fig (figure object): figure object in which the graphs are plotted. """ - from ..exploratory.visualization.representation import plot_curves + from ..exploratory.visualization.representation import ( + plot_hypersurfaces) - return plot_curves(self, *args, **kwargs) + return plot_hypersurfaces(self, *args, **kwargs) @abstractmethod def copy(self, **kwargs): diff --git a/skfda/representation/grid.py b/skfda/representation/grid.py index 475249cfa..7adfdbb69 100644 --- a/skfda/representation/grid.py +++ b/skfda/representation/grid.py @@ -782,19 +782,19 @@ def concatenate(self, *others, as_coordinates=False): else: return self.copy(data_matrix=np.concatenate(data, axis=0)) - def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): + def scatter(self, *args, **kwargs): """Scatter plot of the FDatGrid object. Args: fig (figure object, optional): figure over with the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - ax (list of axis objects, optional): axis over where the graphs + axes (list of axis objects, optional): axis over where the graphs are plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure + n_rows(int, optional): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int, optional): designates the number of columns of the + n_cols(int, optional): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. kwargs: keyword arguments to be passed to the @@ -805,25 +805,9 @@ def scatter(self, fig=None, ax=None, nrows=None, ncols=None, **kwargs): """ - fig, ax = self.generic_plotting_checks(fig, ax, nrows, ncols) + from ..exploratory.visualization.representation import plot_scatter - if self.dim_domain == 1: - for i in range(self.dim_codomain): - for j in range(self.n_samples): - ax[i].scatter(self.sample_points[0], - self.data_matrix[j, :, i].T, **kwargs) - else: - X = self.sample_points[0] - Y = self.sample_points[1] - X, Y = np.meshgrid(X, Y) - for i in range(self.dim_codomain): - for j in range(self.n_samples): - ax[i].scatter(X, Y, self.data_matrix[j, :, :, i].T, - **kwargs) - - self.set_labels(fig, ax) - - return fig + return plot_scatter(self, *args, **kwargs) def to_basis(self, basis, **kwargs): """Return the basis representation of the object. From 3d7e1a5bf991b564095e566133edc1b2335f4893 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Fri, 13 Sep 2019 17:30:24 +0200 Subject: [PATCH 217/222] Correct boxplot and MS-plot --- skfda/_utils/__init__.py | 3 +- skfda/_utils/_utils.py | 24 +- skfda/exploratory/visualization/_boxplot.py | 135 ++++++----- .../visualization/_magnitude_shape_plot.py | 31 ++- skfda/exploratory/visualization/_utils.py | 219 ++++++++++++++++++ skfda/exploratory/visualization/clustering.py | 2 +- .../visualization/representation.py | 165 +------------ skfda/misc/covariances.py | 2 +- 8 files changed, 310 insertions(+), 271 deletions(-) create mode 100644 skfda/exploratory/visualization/_utils.py diff --git a/skfda/_utils/__init__.py b/skfda/_utils/__init__.py index a29cc56a1..6d7d7e221 100644 --- a/skfda/_utils/__init__.py +++ b/skfda/_utils/__init__.py @@ -1,5 +1,4 @@ from . import constants from ._utils import (_list_of_arrays, _coordinate_list, - _check_estimator, parameter_aliases, - _create_figure, _figure_to_svg) + _check_estimator, parameter_aliases) diff --git a/skfda/_utils/_utils.py b/skfda/_utils/_utils.py index 546611644..29142d6fb 100644 --- a/skfda/_utils/_utils.py +++ b/skfda/_utils/_utils.py @@ -1,12 +1,9 @@ """Module with generic methods""" import functools -import io -import types -import matplotlib.backends.backend_svg +import types -import matplotlib.pyplot as plt import numpy as np @@ -141,22 +138,3 @@ def _check_estimator(estimator): instance = estimator() check_get_params_invariance(name, instance) check_set_params(name, instance) - - -def _create_figure(): - """Create figure using the default backend.""" - fig = plt.figure() - - return fig - - -def _figure_to_svg(figure): - """Return the SVG representation of a figure.""" - - old_canvas = figure.canvas - matplotlib.backends.backend_svg.FigureCanvas(figure) - output = io.BytesIO() - figure.savefig(output, format='svg') - figure.set_canvas(old_canvas) - data = output.getvalue() - return data.decode('utf-8') diff --git a/skfda/exploratory/visualization/_boxplot.py b/skfda/exploratory/visualization/_boxplot.py index fa6c4fb57..67bf52609 100644 --- a/skfda/exploratory/visualization/_boxplot.py +++ b/skfda/exploratory/visualization/_boxplot.py @@ -5,7 +5,6 @@ """ from abc import ABC, abstractmethod -from io import BytesIO import math import matplotlib @@ -13,10 +12,10 @@ import matplotlib.pyplot as plt import numpy as np -from ... import FDataGrid -from ..._utils import _create_figure, _figure_to_svg from ..depth import modified_band_depth from ..outliers import _envelopes +from ._utils import (_figure_to_svg, _get_figure_and_axes, + _set_figure_layout_for_fdata, _set_labels) __author__ = "Amanda Hernando Bernabé" @@ -73,7 +72,8 @@ def colormap(self, value): self._colormap = value @abstractmethod - def plot(self, fig=None, ax=None, nrows=None, ncols=None): + def plot(self, chart=None, *, fig=None, axes=None, + n_rows=None, n_cols=None): pass def _repr_svg_(self): @@ -121,6 +121,7 @@ class Boxplot(FDataBoxplot): Example: Function :math:`f : \mathbb{R}\longmapsto\mathbb{R}`. + >>> from skfda import FDataGrid >>> data_matrix = [[1, 1, 2, 3, 2.5, 2], ... [0.5, 0.5, 1, 2, 1.5, 1], ... [-1, -1, -0.5, 1, 1, 0.5], @@ -309,7 +310,8 @@ def show_full_outliers(self, boolean): raise ValueError("show_full_outliers must be boolean type") self._show_full_outliers = boolean - def plot(self, fig=None, ax=None, nrows=None, ncols=None): + def plot(self, chart=None, *, fig=None, axes=None, + n_rows=None, n_cols=None): """Visualization of the functional boxplot of the fdatagrid (dim_domain=1). @@ -317,12 +319,12 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): fig (figure object, optional): figure over with the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - ax (list of axis objects, optional): axis over where the graphs are - plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure + axes (list of axis objects, optional): axis over where the graphs + are plotted. If None, see param fig. + n_rows(int, optional): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int, optional): designates the number of columns of the + n_cols(int, optional): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. @@ -331,8 +333,9 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): """ - fig, ax = self.fdatagrid.generic_plotting_checks(fig, ax, nrows, - ncols) + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout_for_fdata( + self.fdatagrid, fig, axes, n_rows, n_cols) tones = np.linspace(0.1, 1.0, len(self._prob) + 1, endpoint=False)[1:] color = self.colormap(tones) @@ -347,50 +350,50 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): # Outliers for o in outliers: - ax[m].plot(o.sample_points[0], - o.data_matrix[0, :, m], - color=self.outliercol, - linestyle='--', zorder=1) + axes[m].plot(o.sample_points[0], + o.data_matrix[0, :, m], + color=self.outliercol, + linestyle='--', zorder=1) for i in range(len(self._prob)): # central regions - ax[m].fill_between(self.fdatagrid.sample_points[0], - self.envelopes[i][0][..., m], - self.envelopes[i][1][..., m], - facecolor=color[i], zorder=var_zorder) + axes[m].fill_between(self.fdatagrid.sample_points[0], + self.envelopes[i][0][..., m], + self.envelopes[i][1][..., m], + facecolor=color[i], zorder=var_zorder) # outlying envelope - ax[m].plot(self.fdatagrid.sample_points[0], - self.non_outlying_envelope[0][..., m], - self.fdatagrid.sample_points[0], - self.non_outlying_envelope[1][..., m], - color=self.barcol, zorder=4) + axes[m].plot(self.fdatagrid.sample_points[0], + self.non_outlying_envelope[0][..., m], + self.fdatagrid.sample_points[0], + self.non_outlying_envelope[1][..., m], + color=self.barcol, zorder=4) # central envelope - ax[m].plot(self.fdatagrid.sample_points[0], - self.central_envelope[0][..., m], - self.fdatagrid.sample_points[0], - self.central_envelope[1][..., m], - color=self.barcol, zorder=4) + axes[m].plot(self.fdatagrid.sample_points[0], + self.central_envelope[0][..., m], + self.fdatagrid.sample_points[0], + self.central_envelope[1][..., m], + color=self.barcol, zorder=4) # vertical lines index = math.ceil(self.fdatagrid.ncol / 2) x = self.fdatagrid.sample_points[0][index] - ax[m].plot([x, x], - [self.non_outlying_envelope[0][..., m][index], - self.central_envelope[0][..., m][index]], - color=self.barcol, - zorder=4) - ax[m].plot([x, x], - [self.non_outlying_envelope[1][..., m][index], - self.central_envelope[1][..., m][index]], - color=self.barcol, zorder=4) + axes[m].plot([x, x], + [self.non_outlying_envelope[0][..., m][index], + self.central_envelope[0][..., m][index]], + color=self.barcol, + zorder=4) + axes[m].plot([x, x], + [self.non_outlying_envelope[1][..., m][index], + self.central_envelope[1][..., m][index]], + color=self.barcol, zorder=4) # median sample - ax[m].plot(self.fdatagrid.sample_points[0], self.median[..., m], - color=self.mediancol, zorder=5) + axes[m].plot(self.fdatagrid.sample_points[0], self.median[..., m], + color=self.mediancol, zorder=5) - self.fdatagrid.set_labels(fig, ax) + _set_labels(self.fdatagrid, fig, axes) return fig @@ -436,6 +439,7 @@ class SurfaceBoxplot(FDataBoxplot): Example: Function :math:`f : \mathbb{R^2}\longmapsto\mathbb{R}`. + >>> from skfda import FDataGrid >>> data_matrix = [[[[1], [0.7], [1]], ... [[4], [0.4], [5]]], ... [[[2], [0.5], [2]], @@ -584,19 +588,20 @@ def outcol(self, value): "outcol must be a number between 0 and 1.") self._outcol = value - def plot(self, fig=None, ax=None, nrows=None, ncols=None): + def plot(self, chart=None, *, fig=None, axes=None, + n_rows=None, n_cols=None): """Visualization of the surface boxplot of the fdatagrid (dim_domain=2). Args: fig (figure object, optional): figure over with the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - ax (list of axis objects, optional): axis over where the graphs + axes (list of axis objects, optional): axis over where the graphs are plotted. If None, see param fig. - nrows(int, optional): designates the number of rows of the figure + n_rows(int, optional): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int, optional): designates the number of columns of the + n_cols(int, optional): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. @@ -604,8 +609,10 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): fig (figure): figure object in which the graphs are plotted. """ - fig, ax = self.fdatagrid.generic_plotting_checks(fig, ax, nrows, - ncols) + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout_for_fdata( + self.fdatagrid, fig, axes, n_rows, n_cols) + x = self.fdatagrid.sample_points[0] lx = len(x) y = self.fdatagrid.sample_points[1] @@ -615,24 +622,24 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): for m in range(self.fdatagrid.dim_codomain): # mean sample - ax[m].plot_wireframe(X, Y, np.squeeze(self.median[..., m]).T, - rstride=ly, cstride=lx, - color=self.colormap(self.boxcol)) - ax[m].plot_surface(X, Y, np.squeeze(self.median[..., m]).T, - color=self.colormap(self.boxcol), alpha=0.8) + axes[m].plot_wireframe(X, Y, np.squeeze(self.median[..., m]).T, + rstride=ly, cstride=lx, + color=self.colormap(self.boxcol)) + axes[m].plot_surface(X, Y, np.squeeze(self.median[..., m]).T, + color=self.colormap(self.boxcol), alpha=0.8) # central envelope - ax[m].plot_surface( + axes[m].plot_surface( X, Y, np.squeeze(self.central_envelope[0][..., m]).T, color=self.colormap(self.boxcol), alpha=0.5) - ax[m].plot_wireframe( + axes[m].plot_wireframe( X, Y, np.squeeze(self.central_envelope[0][..., m]).T, rstride=ly, cstride=lx, color=self.colormap(self.boxcol)) - ax[m].plot_surface( + axes[m].plot_surface( X, Y, np.squeeze(self.central_envelope[1][..., m]).T, color=self.colormap(self.boxcol), alpha=0.5) - ax[m].plot_wireframe( + axes[m].plot_wireframe( X, Y, np.squeeze(self.central_envelope[1][..., m]).T, rstride=ly, cstride=lx, color=self.colormap(self.boxcol)) @@ -642,7 +649,7 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): (lx - 1, ly - 1)]: x_corner = x[indices[0]] y_corner = y[indices[1]] - ax[m].plot( + axes[m].plot( [x_corner, x_corner], [y_corner, y_corner], [ self.central_envelope[1][..., m][indices[0], @@ -652,20 +659,20 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): color=self.colormap(self.boxcol)) # outlying envelope - ax[m].plot_surface( + axes[m].plot_surface( X, Y, np.squeeze(self.non_outlying_envelope[0][..., m]).T, color=self.colormap(self.outcol), alpha=0.3) - ax[m].plot_wireframe( + axes[m].plot_wireframe( X, Y, np.squeeze(self.non_outlying_envelope[0][..., m]).T, rstride=ly, cstride=lx, color=self.colormap(self.outcol)) - ax[m].plot_surface( + axes[m].plot_surface( X, Y, np.squeeze(self.non_outlying_envelope[1][..., m]).T, color=self.colormap(self.outcol), alpha=0.3) - ax[m].plot_wireframe( + axes[m].plot_wireframe( X, Y, np.squeeze(self.non_outlying_envelope[1][..., m]).T, rstride=ly, cstride=lx, @@ -676,18 +683,18 @@ def plot(self, fig=None, ax=None, nrows=None, ncols=None): x_central = x[x_index] y_index = math.floor(ly / 2) y_central = y[y_index] - ax[m].plot( + axes[m].plot( [x_central, x_central], [y_central, y_central], [self.non_outlying_envelope[1][..., m][x_index, y_index], self.central_envelope[1][..., m][x_index, y_index]], color=self.colormap(self.boxcol)) - ax[m].plot( + axes[m].plot( [x_central, x_central], [y_central, y_central], [self.non_outlying_envelope[0][..., m][x_index, y_index], self.central_envelope[0][..., m][x_index, y_index]], color=self.colormap(self.boxcol)) - self.fdatagrid.set_labels(fig, ax) + _set_labels(self.fdatagrid, fig, axes) return fig diff --git a/skfda/exploratory/visualization/_magnitude_shape_plot.py b/skfda/exploratory/visualization/_magnitude_shape_plot.py index fde6fa435..345e6457f 100644 --- a/skfda/exploratory/visualization/_magnitude_shape_plot.py +++ b/skfda/exploratory/visualization/_magnitude_shape_plot.py @@ -6,19 +6,14 @@ """ -from io import BytesIO - import matplotlib -from scipy.stats import f, variation -from sklearn.covariance import MinCovDet import matplotlib.pyplot as plt import numpy as np -from ..._utils import _create_figure, _figure_to_svg from ..depth import modified_band_depth -from ..outliers import (directional_outlyingness_stats, - DirectionalOutlierDetector) +from ..outliers import DirectionalOutlierDetector +from ._utils import _figure_to_svg, _get_figure_and_axes, _set_figure_layout __author__ = "Amanda Hernando Bernabé" @@ -241,7 +236,7 @@ def outliercol(self, value): "outcol must be a number between 0 and 1.") self._outliercol = value - def plot(self, ax=None): + def plot(self, chart=None, *, fig=None, axes=None,): """Visualization of the magnitude shape plot of the fdatagrid. Args: @@ -252,23 +247,23 @@ def plot(self, ax=None): fig (figure object): figure object in which the graph is plotted. """ + + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout(fig, axes) + colors = np.zeros((self.fdatagrid.n_samples, 4)) colors[np.where(self.outliers == 1)] = self.colormap(self.outliercol) colors[np.where(self.outliers == 0)] = self.colormap(self.color) - if ax is None: - fig = _create_figure() - ax = fig.add_subplot(1, 1, 1) - colors_rgba = [tuple(i) for i in colors] - ax.scatter(self.points[:, 0].ravel(), self.points[:, 1].ravel(), - color=colors_rgba) + axes[0].scatter(self.points[:, 0].ravel(), self.points[:, 1].ravel(), + color=colors_rgba) - ax.set_xlabel(self.xlabel) - ax.set_ylabel(self.ylabel) - ax.set_title(self.title) + axes[0].set_xlabel(self.xlabel) + axes[0].set_ylabel(self.ylabel) + axes[0].set_title(self.title) - return ax.get_figure() + return fig def __repr__(self): """Return repr(self).""" diff --git a/skfda/exploratory/visualization/_utils.py b/skfda/exploratory/visualization/_utils.py new file mode 100644 index 000000000..fcbe9820b --- /dev/null +++ b/skfda/exploratory/visualization/_utils.py @@ -0,0 +1,219 @@ +import io +import math + +import matplotlib.axes +import matplotlib.backends.backend_svg +import matplotlib.figure + +import matplotlib.pyplot as plt + + +def _create_figure(): + """Create figure using the default backend.""" + fig = plt.figure() + + return fig + + +def _figure_to_svg(figure): + """Return the SVG representation of a figure.""" + + old_canvas = figure.canvas + matplotlib.backends.backend_svg.FigureCanvas(figure) + output = io.BytesIO() + figure.savefig(output, format='svg') + figure.set_canvas(old_canvas) + data = output.getvalue() + return data.decode('utf-8') + + +def _get_figure_and_axes(chart=None, fig=None, axes=None): + """Obtain the figure and axes from the arguments.""" + + num_defined = sum(e is not None for e in (chart, fig, axes)) + if num_defined > 1: + raise ValueError("Only one of chart, fig and axes parameters" + "can be passed as an argument.") + + # Parse chart argument + if chart is not None: + if isinstance(chart, matplotlib.figure.Figure): + fig = chart + else: + axes = chart + + if fig is None and axes is None: + fig = fig = _create_figure() + axes = [] + + elif fig is not None: + axes = fig.axes + + else: + if isinstance(axes, matplotlib.axes.Axes): + axes = [axes] + + fig = axes[0].figure + + return fig, axes + + +def _get_axes_shape(n_axes, n_rows=None, n_cols=None): + """Get the number of rows and columns of the subplots""" + + if ((n_rows is not None and n_cols is not None) + and ((n_rows * n_cols) < n_axes)): + raise ValueError(f"The number of rows ({n_rows}) multiplied by " + f"the number of columns ({n_cols}) " + f"is less than the number of required " + f"axes ({n_axes})") + + if n_rows is None and n_cols is None: + n_cols = int(math.ceil(math.sqrt(n_axes))) + n_rows = int(math.ceil(n_axes / n_cols)) + elif n_rows is None and n_cols is not None: + n_rows = int(math.ceil(n_axes / n_cols)) + elif n_cols is None and n_rows is not None: + n_cols = int(math.ceil(n_axes / n_rows)) + + return n_rows, n_cols + + +def _set_figure_layout(fig=None, axes=None, + dim=2, n_axes=1, + n_rows=None, n_cols=None): + """Set the figure axes for plotting. + + Args: + dim (int): dimension of the plot. Either 2 for a 2D plot or + 3 for a 3D plot. + n_axes (int): Number of subplots. + fig (figure object): figure over with the graphs are + plotted in case ax is not specified. + ax (list of axis objects): axis over where the graphs are + plotted. + n_rows (int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Can only be passed + if no axes are specified. + n_cols (int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Can only be + passed if no axes are specified. + + Returns: + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * axes (list): axes in which the graphs are plotted. + + """ + if not (1 < dim < 4): + raise NotImplementedError("Only bidimensional or tridimensional " + "plots are supported.") + + if len(axes) != 0 and len(axes) != n_axes: + raise ValueError(f"The number of axes must be 0 (to create them) or " + f"equal to the number of axes needed " + f"({n_axes} in this case).") + + if len(axes) != 0 and (n_rows is not None or n_cols is not None): + raise ValueError("The number of columns and/or number of rows of " + "the figure, in which each dimension of the " + "image is plotted, can only be customized in case " + "that no axes are provided.") + + if dim == 2: + projection = 'rectilinear' + else: + projection = '3d' + + if len(axes) == 0: + # Create the axes + + n_rows, n_cols = _get_axes_shape(n_axes, n_rows, n_cols) + fig.subplots(nrows=n_rows, ncols=n_cols, + subplot_kw={"projection": projection}) + axes = fig.axes + + else: + # Check that the projections are right + + if not all(a.name == projection for a in axes): + raise ValueError(f"The projection of the axes should be " + f"{projection}") + + return fig, axes + + +def _set_figure_layout_for_fdata(fdata, fig=None, axes=None, + n_rows=None, n_cols=None): + """Set the figure axes for plotting a + :class:`~skfda.representation.FData` object. + + Args: + fdata (FData): functional data object. + fig (figure object): figure over with the graphs are + plotted in case ax is not specified. + ax (list of axis objects): axis over where the graphs are + plotted. + n_rows (int, optional): designates the number of rows of the figure + to plot the different dimensions of the image. Can only be passed + if no axes are specified. + n_cols (int, optional): designates the number of columns of the + figure to plot the different dimensions of the image. Can only be + passed if no axes are specified. + + Returns: + (tuple): tuple containing: + + * fig (figure): figure object in which the graphs are plotted. + * axes (list): axes in which the graphs are plotted. + + """ + return _set_figure_layout(fig, axes, + dim=fdata.dim_domain + 1, + n_axes=fdata.dim_codomain, + n_rows=n_rows, n_cols=n_cols) + + +def _set_labels(fdata, fig=None, axes=None, patches=None): + """Set labels if any. + + Args: + fdata (FData): functional data object. + fig (figure object): figure object containing the axes that + implement set_xlabel and set_ylabel, and set_zlabel in case + of a 3d projection. + axes (list of axes): axes objects that implement set_xlabel and + set_ylabel, and set_zlabel in case of a 3d projection; used if + fig is None. + patches (list of mpatches.Patch); objects used to generate each + entry in the legend. + + """ + + # Dataset name + if fdata.dataset_label is not None: + fig.suptitle(fdata.dataset_label) + + # Legend + if patches is not None: + fig.legend(handles=patches) + elif patches is not None: + axes[0].legend(handles=patches) + + # Axis labels + if fdata.axes_labels is not None: + if axes[0].name == '3d': + for i in range(fdata.dim_codomain): + if fdata.axes_labels[0] is not None: + axes[i].set_xlabel(fdata.axes_labels[0]) + if fdata.axes_labels[1] is not None: + axes[i].set_ylabel(fdata.axes_labels[1]) + if fdata.axes_labels[i + 2] is not None: + axes[i].set_zlabel(fdata.axes_labels[i + 2]) + else: + for i in range(fdata.dim_codomain): + if fdata.axes_labels[0] is not None: + axes[i].set_xlabel(fdata.axes_labels[0]) + if fdata.axes_labels[i + 1] is not None: + axes[i].set_ylabel(fdata.axes_labels[i + 1]) diff --git a/skfda/exploratory/visualization/clustering.py b/skfda/exploratory/visualization/clustering.py index ac9368397..e8fe4f03c 100644 --- a/skfda/exploratory/visualization/clustering.py +++ b/skfda/exploratory/visualization/clustering.py @@ -10,8 +10,8 @@ import matplotlib.pyplot as plt import numpy as np -from ..._utils import _create_figure from ...ml.clustering.base_kmeans import FuzzyKMeans +from ._utils import _create_figure __author__ = "Amanda Hernando Bernabé" diff --git a/skfda/exploratory/visualization/representation.py b/skfda/exploratory/visualization/representation.py index 1e97860ca..8fd466aee 100644 --- a/skfda/exploratory/visualization/representation.py +++ b/skfda/exploratory/visualization/representation.py @@ -1,127 +1,12 @@ -import math -import matplotlib.axes import matplotlib.cm -import matplotlib.figure import matplotlib.patches import numpy as np -from ..._utils import _create_figure, _list_of_arrays, constants - - -def _get_figure_and_axes(chart=None, fig=None, axes=None): - """Obtain the figure and axes from the arguments.""" - - num_defined = sum(e is not None for e in (chart, fig, axes)) - if num_defined > 1: - raise ValueError("Only one of chart, fig and axes parameters" - "can be passed as an argument.") - - # Parse chart argument - if chart is not None: - if isinstance(chart, matplotlib.figure.Figure): - fig = chart - else: - axes = chart - - if fig is None and axes is None: - fig = fig = _create_figure() - axes = [] - - elif fig is not None: - axes = fig.axes - - else: - if isinstance(axes, matplotlib.axes.Axes): - axes = [axes] - - fig = axes[0].figure - - return fig, axes - - -def _get_axes_shape(fdata, n_rows=None, n_cols=None): - """Get the number of rows and columns of the subplots""" - - if ((n_rows is not None and n_cols is not None) - and ((n_rows * n_cols) < fdata.dim_codomain)): - raise ValueError(f"The number of rows ({n_rows}) multiplied by " - f"the number of columns ({n_cols}) " - f"is less than the dimension of " - f"the image ({fdata.dim_codomain})") - - if n_rows is None and n_cols is None: - n_cols = int(math.ceil(math.sqrt(fdata.dim_codomain))) - n_rows = int(math.ceil(fdata.dim_codomain / n_cols)) - elif n_rows is None and n_cols is not None: - n_rows = int(math.ceil(fdata.dim_codomain / n_cols)) - elif n_cols is None and n_rows is not None: - n_cols = int(math.ceil(fdata.dim_codomain / n_rows)) - - return n_rows, n_cols - - -def _set_figure_layout_for_fdata(fdata, fig=None, axes=None, - n_rows=None, n_cols=None): - """Set the figure axes for plotting a - :class:`~skfda.representation.FData` object. - - Args: - fdata (FData): functional data object. - fig (figure object): figure over with the graphs are - plotted in case ax is not specified. - ax (list of axis objects): axis over where the graphs are - plotted. - n_rows (int, optional): designates the number of rows of the figure - to plot the different dimensions of the image. Can only be passed - if no axes are specified. - n_cols (int, optional): designates the number of columns of the - figure to plot the different dimensions of the image. Can only be - passed if no axes are specified. - - Returns: - (tuple): tuple containing: - - * fig (figure): figure object in which the graphs are plotted. - * axes (list): axes in which the graphs are plotted. - - """ - if fdata.dim_domain > 2: - raise NotImplementedError("Plot only supported for functional data" - " modeled in at most 3 dimensions.") - - if len(axes) != 0 and len(axes) != fdata.dim_codomain: - raise ValueError("The number of axes must be 0 (to create them) or " - "equal to the dimension of the image.") - - if len(axes) != 0 and (n_rows is not None or n_cols is not None): - raise ValueError("The number of columns and/or number of rows of " - "the figure, in which each dimension of the " - "image is plotted, can only be customized in case " - "that no axes are provided.") - - if fdata.dim_domain == 1: - projection = 'rectilinear' - else: - projection = '3d' - - if len(axes) == 0: - # Create the axes - - n_rows, n_cols = _get_axes_shape(fdata, n_rows, n_cols) - fig.subplots(nrows=n_rows, ncols=n_cols, - subplot_kw={"projection": projection}) - axes = fig.axes - - else: - # Check that the projections are right - - if not all(a.name == projection for a in axes): - raise ValueError(f"The projection of the axes should be " - f"{projection}") - - return fig, axes +from ..._utils import _list_of_arrays, constants +from ._utils import (_get_figure_and_axes, _set_figure_layout_for_fdata, + _set_labels) def _get_label_colors(n_labels, label_colors=None): @@ -187,50 +72,6 @@ def _get_color_info(fdata, sample_labels, label_names, label_colors, kwargs): return sample_colors, patches -def _set_labels(fdata, fig=None, axes=None, patches=None): - """Set labels if any. - - Args: - fdata (FData): functional data object. - fig (figure object): figure object containing the axes that - implement set_xlabel and set_ylabel, and set_zlabel in case - of a 3d projection. - axes (list of axes): axes objects that implement set_xlabel and - set_ylabel, and set_zlabel in case of a 3d projection; used if - fig is None. - patches (list of mpatches.Patch); objects used to generate each - entry in the legend. - - """ - - # Dataset name - if fdata.dataset_label is not None: - fig.suptitle(fdata.dataset_label) - - # Legend - if patches is not None and fdata.dim_codomain > 1: - fig.legend(handles=patches) - elif patches is not None: - axes[0].legend(handles=patches) - - # Axis labels - if fdata.axes_labels is not None: - if axes[0].name == '3d': - for i in range(fdata.dim_codomain): - if fdata.axes_labels[0] is not None: - axes[i].set_xlabel(fdata.axes_labels[0]) - if fdata.axes_labels[1] is not None: - axes[i].set_ylabel(fdata.axes_labels[1]) - if fdata.axes_labels[i + 2] is not None: - axes[i].set_zlabel(fdata.axes_labels[i + 2]) - else: - for i in range(fdata.dim_codomain): - if fdata.axes_labels[0] is not None: - axes[i].set_xlabel(fdata.axes_labels[0]) - if fdata.axes_labels[i + 1] is not None: - axes[i].set_ylabel(fdata.axes_labels[i + 1]) - - def plot_hypersurfaces(fdata, chart=None, *, derivative=0, fig=None, axes=None, n_rows=None, n_cols=None, n_points=None, domain_range=None, diff --git a/skfda/misc/covariances.py b/skfda/misc/covariances.py index 0b5c1136e..f433a38a3 100644 --- a/skfda/misc/covariances.py +++ b/skfda/misc/covariances.py @@ -6,7 +6,7 @@ import numpy as np import sklearn.gaussian_process.kernels as sklearn_kern -from .._utils import _create_figure, _figure_to_svg +from ..exploratory.visualization._utils import _create_figure, _figure_to_svg def _squared_norms(x, y): From d1747b0ecf8a564b871cef2cb7f6180527761cb0 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Mon, 16 Sep 2019 13:15:29 +0200 Subject: [PATCH 218/222] Fix clustering plots --- skfda/exploratory/visualization/_utils.py | 37 +++ skfda/exploratory/visualization/clustering.py | 277 +++++++----------- 2 files changed, 137 insertions(+), 177 deletions(-) diff --git a/skfda/exploratory/visualization/_utils.py b/skfda/exploratory/visualization/_utils.py index fcbe9820b..e946d8410 100644 --- a/skfda/exploratory/visualization/_utils.py +++ b/skfda/exploratory/visualization/_utils.py @@ -217,3 +217,40 @@ def _set_labels(fdata, fig=None, axes=None, patches=None): axes[i].set_xlabel(fdata.axes_labels[0]) if fdata.axes_labels[i + 1] is not None: axes[i].set_ylabel(fdata.axes_labels[i + 1]) + + +def _change_luminosity(color, amount=0.5): + """ + Changes the given color luminosity by the given amount. + Input can be matplotlib color string, hex string, or RGB tuple. + + Note: + Based on https://stackoverflow.com/a/49601444/2455333 + """ + import matplotlib.colors as mc + import colorsys + try: + c = mc.cnames[color] + except TypeError: + c = color + c = colorsys.rgb_to_hls(*mc.to_rgb(c)) + + intensity = (amount - 0.5) * 2 + up = intensity > 0 + intensity = abs(intensity) + + lightness = c[1] + if up: + new_lightness = lightness + intensity * (1 - lightness) + else: + new_lightness = lightness - intensity * lightness + + return colorsys.hls_to_rgb(c[0], new_lightness, c[2]) + + +def _darken(color, amount=0): + return _change_luminosity(color, 0.5 - amount / 2) + + +def _lighten(color, amount=0): + return _change_luminosity(color, 0.5 + amount / 2) diff --git a/skfda/exploratory/visualization/clustering.py b/skfda/exploratory/visualization/clustering.py index e8fe4f03c..a266da294 100644 --- a/skfda/exploratory/visualization/clustering.py +++ b/skfda/exploratory/visualization/clustering.py @@ -1,7 +1,5 @@ """Clustering Plots Module.""" -import warnings - from matplotlib.ticker import MaxNLocator from mpldatacursor import datacursor from sklearn.exceptions import NotFittedError @@ -11,50 +9,15 @@ import numpy as np from ...ml.clustering.base_kmeans import FuzzyKMeans -from ._utils import _create_figure +from ._utils import (_darken, + _get_figure_and_axes, _set_figure_layout_for_fdata, + _set_figure_layout, _set_labels) __author__ = "Amanda Hernando Bernabé" __email__ = "amanda.hernando@estudiante.uam.es" -def _change_luminosity(color, amount=0.5): - """ - Changes the given color luminosity by the given amount. - Input can be matplotlib color string, hex string, or RGB tuple. - - Note: - Based on https://stackoverflow.com/a/49601444/2455333 - """ - import matplotlib.colors as mc - import colorsys - try: - c = mc.cnames[color] - except TypeError: - c = color - c = colorsys.rgb_to_hls(*mc.to_rgb(c)) - - intensity = (amount - 0.5) * 2 - up = intensity > 0 - intensity = abs(intensity) - - lightness = c[1] - if up: - new_lightness = lightness + intensity * (1 - lightness) - else: - new_lightness = lightness - intensity * lightness - - return colorsys.hls_to_rgb(c[0], new_lightness, c[2]) - - -def _darken(color, amount=0): - return _change_luminosity(color, 0.5 - amount / 2) - - -def _lighten(color, amount=0): - return _change_luminosity(color, 0.5 + amount / 2) - - def _check_if_estimator(estimator): """Checks the argument *estimator* is actually an estimator that implements the *fit* method. @@ -68,7 +31,7 @@ def _check_if_estimator(estimator): raise AttributeError(msg % {'name': type(estimator).__name__}) -def _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, +def _plot_clustering_checks(estimator, fdata, sample_colors, sample_labels, cluster_colors, cluster_labels, center_colors, center_labels): """Checks the arguments *sample_colors*, *sample_labels*, *cluster_colors*, @@ -78,7 +41,7 @@ def _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, Args: estimator (BaseEstimator object): estimator used to calculate the clusters. - fdatagrid (FDataGrd object): contains the samples which are grouped + fdata (FData object): contains the samples which are grouped into different clusters. sample_colors (list of colors): contains in order the colors of each sample of the fdatagrid. @@ -98,12 +61,12 @@ def _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, """ if sample_colors is not None and len( - sample_colors) != fdatagrid.n_samples: + sample_colors) != fdata.n_samples: raise ValueError( "sample_colors must contain a color for each sample.") if sample_labels is not None and len( - sample_labels) != fdatagrid.n_samples: + sample_labels) != fdata.n_samples: raise ValueError( "sample_labels must contain a label for each sample.") @@ -128,8 +91,9 @@ def _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, "centers_labels must contain a label for each center.") -def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, - sample_labels, cluster_colors, cluster_labels, +def _plot_clusters(estimator, fdata, *, chart=None, fig=None, axes=None, + n_rows=None, n_cols=None, + labels, sample_labels, cluster_colors, cluster_labels, center_colors, center_labels, center_width, colormap): """Implementation of the plot of the FDataGrid samples by clusters. @@ -141,12 +105,12 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, fig (figure object): figure over which the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - ax (list of axis objects): axis over where the graphs are plotted. + axes (list of axes objects): axes over where the graphs are plotted. If None, see param fig. - nrows(int): designates the number of rows of the figure to plot the + n_rows(int): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int): designates the number of columns of the figure to plot + n_cols(int): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. labels (numpy.ndarray, int: (n_samples, dim_codomain)): 2-dimensional @@ -176,30 +140,30 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, ax (axes object): axes in which the graphs are plotted. """ - fig, ax = fdatagrid.generic_plotting_checks(fig, ax, nrows, ncols) + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout_for_fdata(fdata, fig, axes, n_rows, n_cols) - _plot_clustering_checks(estimator, fdatagrid, None, sample_labels, + _plot_clustering_checks(estimator, fdata, None, sample_labels, cluster_colors, cluster_labels, center_colors, center_labels) if sample_labels is None: - sample_labels = ['$SAMPLE: {}$'.format(i) for i in - range(fdatagrid.n_samples)] + sample_labels = [f'$SAMPLE: {i}$' for i in range(fdata.n_samples)] if cluster_colors is None: cluster_colors = colormap( np.arange(estimator.n_clusters) / (estimator.n_clusters - 1)) if cluster_labels is None: - cluster_labels = ['$CLUSTER: {}$'.format(i) for i in - range(estimator.n_clusters)] + cluster_labels = [ + f'$CLUSTER: {i}$' for i in range(estimator.n_clusters)] if center_colors is None: center_colors = [_darken(c, 0.5) for c in cluster_colors] if center_labels is None: - center_labels = ['$CENTER: {}$'.format(i) for i in - range(estimator.n_clusters)] + center_labels = [ + f'$CENTER: {i}$' for i in range(estimator.n_clusters)] colors_by_cluster = cluster_colors[labels] @@ -209,29 +173,31 @@ def _plot_clusters(estimator, fdatagrid, fig, ax, nrows, ncols, labels, mpatches.Patch(color=cluster_colors[i], label=cluster_labels[i])) - for j in range(fdatagrid.dim_codomain): - for i in range(fdatagrid.n_samples): - ax[j].plot(fdatagrid.sample_points[0], - fdatagrid.data_matrix[i, :, j], - c=colors_by_cluster[i], - label=sample_labels[i]) + for j in range(fdata.dim_codomain): + for i in range(fdata.n_samples): + axes[j].plot(fdata.sample_points[0], + fdata.data_matrix[i, :, j], + c=colors_by_cluster[i], + label=sample_labels[i]) for i in range(estimator.n_clusters): - ax[j].plot(fdatagrid.sample_points[0], - estimator.cluster_centers_.data_matrix[i, :, j], - c=center_colors[i], - label=center_labels[i], - linewidth=center_width) - ax[j].legend(handles=patches) + axes[j].plot(fdata.sample_points[0], + estimator.cluster_centers_.data_matrix[i, :, j], + c=center_colors[i], + label=center_labels[i], + linewidth=center_width) + axes[j].legend(handles=patches) datacursor(formatter='{label}'.format) - fdatagrid.set_labels(fig, ax) + _set_labels(fdata, fig, axes) - return fig, ax + return fig -def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, +def plot_clusters(estimator, X, chart=None, fig=None, axes=None, + n_rows=None, n_cols=None, sample_labels=None, cluster_colors=None, - cluster_labels=None, center_colors=None, center_labels=None, + cluster_labels=None, center_colors=None, + center_labels=None, center_width=3, colormap=plt.cm.get_cmap('rainbow')): """Plot of the FDataGrid samples by clusters. @@ -249,12 +215,12 @@ def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, fig (figure object): figure over which the graphs are plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - ax (list of axis objects): axis over where the graphs are plotted. + axes (list of axis objects): axis over where the graphs are plotted. If None, see param fig. - nrows(int): designates the number of rows of the figure to plot the + n_rows (int): designates the number of rows of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. - ncols(int): designates the number of columns of the figure to plot + n_cols (int): designates the number of columns of the figure to plot the different dimensions of the image. Only specified if fig and ax are None. sample_labels (list of str): contains in order the labels of each @@ -293,8 +259,8 @@ def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, else: labels = estimator.labels_ - return _plot_clusters(estimator=estimator, fdatagrid=X, - fig=fig, ax=ax, nrows=nrows, ncols=ncols, + return _plot_clusters(estimator=estimator, fdata=X, + fig=fig, axes=axes, n_rows=n_rows, n_cols=n_cols, labels=labels, sample_labels=sample_labels, cluster_colors=cluster_colors, cluster_labels=cluster_labels, @@ -304,7 +270,7 @@ def plot_clusters(estimator, X, fig=None, ax=None, nrows=None, ncols=None, colormap=colormap) -def _set_labels(xlabel, ylabel, title, xlabel_str): +def _get_labels(x_label, y_label, title, xlabel_str): """Sets the arguments *xlabel*, *ylabel*, *title* passed to the plot functions :func:`plot_cluster_lines ` and @@ -326,70 +292,23 @@ def _set_labels(xlabel, ylabel, title, xlabel_str): title (str): Title for the figure where the clustering results are plotted. """ - if xlabel is None: - xlabel = xlabel_str + if x_label is None: + x_label = xlabel_str - if ylabel is None: - ylabel = "Degree of membership" + if y_label is None: + y_label = "Degree of membership" if title is None: title = "Degrees of membership of the samples to each cluster" - return xlabel, ylabel, title - - -def _fig_and_ax_checks(fig, ax): - """Checks the arguments *fig* and *ax* passed to the plot - functions :func:`plot_cluster_lines - ` and - :func:`plot_cluster_bars - `. - In case they are not set yet, they are initialised. - - Args: - fig (figure object): figure over which the graph is - plotted in case ax is not specified. If None and ax is also None, - the figure is initialized. - ax (axis object): axis over where the graph is plotted. - If None, see param fig. - """ - if fig is not None and ax is not None: - raise ValueError("fig and axes parameters cannot be passed as " - "arguments at the same time.") - - if fig is not None and len(fig.get_axes()) > 1: - warnings.warn("Warning: The first axis of the figure is where " - "the graph is going to be shown.") - - if fig is None and ax is None: - fig = _create_figure() - - if ax is None: - axes = fig.get_axes() - if len(axes) != 0: - if axes[0].name == '3d': - fig = plt.figure() - fig.add_subplot(111) - ax = fig.get_axes()[0] - else: - ax = axes[0] - else: - fig.add_subplot(111) - ax = fig.get_axes()[0] - else: - if ax.name == '3d': - fig = plt.figure() - fig.add_subplot(111) - ax = fig.get_axes()[0] - fig = ax.get_figure() - - return fig, ax + return x_label, y_label, title -def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, - sample_labels=None, cluster_labels=None, - colormap=plt.cm.get_cmap('rainbow'), xlabel=None, - ylabel=None, title=None): +def plot_cluster_lines(estimator, X, chart=None, fig=None, axes=None, + sample_colors=None, sample_labels=None, + cluster_labels=None, + colormap=plt.cm.get_cmap('rainbow'), + x_label=None, y_label=None, title=None): """Implementation of the plotting of the results of the :func:`Fuzzy K-Means ` method. @@ -407,7 +326,7 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, fig (figure object, optional): figure over which the graph is plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - ax (axis object, optional): axis over where the graph is plotted. + axes (axes object, optional): axis over where the graph is plotted. If None, see param fig. sample_colors (list of colors, optional): contains in order the colors of each sample of the fdatagrid. @@ -417,8 +336,9 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, each cluster the samples of the fdatagrid are classified into. colormap(colormap, optional): colormap from which the colors of the plot are taken. - xlabel (str): Label for the x-axis. Defaults to "Sample". - ylabel (str): Label for the y-axis. Defaults to "Degree of membership". + x_label (str): Label for the x-axis. Defaults to "Cluster". + y_label (str): Label for the y-axis. Defaults to + "Degree of membership". title (str, optional): Title for the figure where the clustering results are ploted. Defaults to "Degrees of membership of the samples to each cluster". @@ -432,7 +352,7 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, ax (axes object): axes in which the graphs are plotted. """ - fdatagrid = X + fdata = X _check_if_estimator(estimator) if not isinstance(estimator, FuzzyKMeans): @@ -444,12 +364,13 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, except NotFittedError: estimator.fit(X) - fig, ax = _fig_and_ax_checks(fig, ax) + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout(fig, axes) - _plot_clustering_checks(estimator, fdatagrid, sample_colors, sample_labels, + _plot_clustering_checks(estimator, fdata, sample_colors, sample_labels, None, cluster_labels, None, None) - xlabel, ylabel, title = _set_labels(xlabel, ylabel, title, "Cluster") + x_label, y_label, title = _get_labels(x_label, y_label, title, "Cluster") if sample_colors is None: cluster_colors = colormap(np.arange(estimator.n_clusters) / @@ -459,32 +380,32 @@ def plot_cluster_lines(estimator, X, fig=None, ax=None, sample_colors=None, if sample_labels is None: sample_labels = ['$SAMPLE: {}$'.format(i) for i in - range(fdatagrid.n_samples)] + range(fdata.n_samples)] if cluster_labels is None: cluster_labels = ['${}$'.format(i) for i in range(estimator.n_clusters)] - ax.get_xaxis().set_major_locator(MaxNLocator(integer=True)) - for i in range(fdatagrid.n_samples): - ax.plot(np.arange(estimator.n_clusters), - estimator.labels_[i], - label=sample_labels[i], - color=sample_colors[i]) - ax.set_xticks(np.arange(estimator.n_clusters)) - ax.set_xticklabels(cluster_labels) - ax.set_xlabel(xlabel) - ax.set_ylabel(ylabel) + axes[0].get_xaxis().set_major_locator(MaxNLocator(integer=True)) + for i in range(fdata.n_samples): + axes[0].plot(np.arange(estimator.n_clusters), + estimator.labels_[i], + label=sample_labels[i], + color=sample_colors[i]) + axes[0].set_xticks(np.arange(estimator.n_clusters)) + axes[0].set_xticklabels(cluster_labels) + axes[0].set_xlabel(x_label) + axes[0].set_ylabel(y_label) datacursor(formatter='{label}'.format) fig.suptitle(title) - return fig, ax + return fig -def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, +def plot_cluster_bars(estimator, X, chart=None, fig=None, axes=None, sort=-1, sample_labels=None, cluster_colors=None, cluster_labels=None, colormap=plt.cm.get_cmap('rainbow'), - xlabel=None, ylabel=None, title=None): + x_label=None, y_label=None, title=None): """Implementation of the plotting of the results of the :func:`Fuzzy K-Means ` method. @@ -503,7 +424,7 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, fig (figure object, optional): figure over which the graph is plotted in case ax is not specified. If None and ax is also None, the figure is initialized. - ax (axis object, optional): axis over where the graph is plotted. + axes (axes object, optional): axes over where the graph is plotted. If None, see param fig. sort(int, optional): Number in the range [-1, n_clusters) designating the cluster whose labels are sorted in a decrementing order. @@ -516,8 +437,9 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, cluster the samples of the fdatagrid are classified into. colormap(colormap, optional): colormap from which the colors of the plot are taken. - xlabel (str): Label for the x-axis. Defaults to "Sample". - ylabel (str): Label for the y-axis. Defaults to "Degree of membership". + x_label (str): Label for the x-axis. Defaults to "Sample". + y_label (str): Label for the y-axis. Defaults to + "Degree of membership". title (str): Title for the figure where the clustering results are plotted. Defaults to "Degrees of membership of the samples to each cluster". @@ -531,7 +453,7 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, ax (axis object): axis in which the graph is plotted. """ - fdatagrid = X + fdata = X _check_if_estimator(estimator) if not isinstance(estimator, FuzzyKMeans): @@ -547,22 +469,23 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, raise ValueError( "The sorting number must belong to the interval [-1, n_clusters)") - fig, ax = _fig_and_ax_checks(fig, ax) + fig, axes = _get_figure_and_axes(chart, fig, axes) + fig, axes = _set_figure_layout(fig, axes) - _plot_clustering_checks(estimator, fdatagrid, None, sample_labels, + _plot_clustering_checks(estimator, fdata, None, sample_labels, cluster_colors, cluster_labels, None, None) - xlabels, ylabels, title = _set_labels(xlabel, ylabel, title, "Sample") + x_label, y_label, title = _get_labels(x_label, y_label, title, "Sample") if sample_labels is None: - sample_labels = np.arange(fdatagrid.n_samples) + sample_labels = np.arange(fdata.n_samples) if cluster_colors is None: cluster_colors = colormap( np.arange(estimator.n_clusters) / (estimator.n_clusters - 1)) if cluster_labels is None: - cluster_labels = ['$CLUSTER: {}$'.format(i) for i in + cluster_labels = [f'$CLUSTER: {i}$' for i in range(estimator.n_clusters)] patches = [] @@ -585,18 +508,18 @@ def plot_cluster_bars(estimator, X, fig=None, ax=None, sort=-1, else: labels_dim = estimator.labels_ - conc = np.zeros((fdatagrid.n_samples, 1)) + conc = np.zeros((fdata.n_samples, 1)) labels_dim = np.concatenate((conc, labels_dim), axis=-1) for i in range(estimator.n_clusters): - ax.bar(np.arange(fdatagrid.n_samples), - labels_dim[:, i + 1], - bottom=np.sum(labels_dim[:, :(i + 1)], axis=1), - color=cluster_colors[i]) - ax.set_xticks(np.arange(fdatagrid.n_samples)) - ax.set_xticklabels(sample_labels) - ax.set_xlabel(xlabel) - ax.set_ylabel(ylabel) - ax.legend(handles=patches) + axes[0].bar(np.arange(fdata.n_samples), + labels_dim[:, i + 1], + bottom=np.sum(labels_dim[:, :(i + 1)], axis=1), + color=cluster_colors[i]) + axes[0].set_xticks(np.arange(fdata.n_samples)) + axes[0].set_xticklabels(sample_labels) + axes[0].set_xlabel(x_label) + axes[0].set_ylabel(y_label) + axes[0].legend(handles=patches) fig.suptitle(title) - return fig, ax + return fig From d030c9799b57ef1f3faa9b7f9c77c29a92b9c6f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Ramos=20Carre=C3=B1o?= Date: Tue, 17 Sep 2019 12:36:00 +0200 Subject: [PATCH 219/222] Update skfda/exploratory/visualization/_utils.py Co-Authored-By: Pablo Marcos --- skfda/exploratory/visualization/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/exploratory/visualization/_utils.py b/skfda/exploratory/visualization/_utils.py index e946d8410..9fd0d6198 100644 --- a/skfda/exploratory/visualization/_utils.py +++ b/skfda/exploratory/visualization/_utils.py @@ -43,7 +43,7 @@ def _get_figure_and_axes(chart=None, fig=None, axes=None): axes = chart if fig is None and axes is None: - fig = fig = _create_figure() + fig = _create_figure() axes = [] elif fig is not None: From 0c800cacc0d587b691716c0278c0910f6ce916f2 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 17 Sep 2019 12:53:06 +0200 Subject: [PATCH 220/222] Fixes suggested. Fix `color` argument not taking effect. Rename `plot_hypersurfaces` to `plot_graph`. --- .../visualization/representation.py | 20 +++++++++---------- skfda/representation/_functional_data.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/skfda/exploratory/visualization/representation.py b/skfda/exploratory/visualization/representation.py index 8fd466aee..e76d38e9f 100644 --- a/skfda/exploratory/visualization/representation.py +++ b/skfda/exploratory/visualization/representation.py @@ -72,12 +72,12 @@ def _get_color_info(fdata, sample_labels, label_names, label_colors, kwargs): return sample_colors, patches -def plot_hypersurfaces(fdata, chart=None, *, derivative=0, fig=None, axes=None, - n_rows=None, n_cols=None, n_points=None, - domain_range=None, - sample_labels=None, label_colors=None, label_names=None, - **kwargs): - """Plot the FDatGrid object as hypersurfaces. +def plot_graph(fdata, chart=None, *, derivative=0, fig=None, axes=None, + n_rows=None, n_cols=None, n_points=None, + domain_range=None, + sample_labels=None, label_colors=None, label_names=None, + **kwargs): + """Plot the FDatGrid object graph as hypersurfaces. Plots each coordinate separately. If the domain is one dimensional, the plots will be curves, and if it is two dimensional, they will be surfaces. @@ -161,7 +161,7 @@ def plot_hypersurfaces(fdata, chart=None, *, derivative=0, fig=None, axes=None, for i in range(fdata.dim_codomain): for j in range(fdata.n_samples): - if sample_labels is not None: + if sample_colors is not None: color_dict["color"] = sample_colors[j] axes[i].plot(eval_points, mat[j, ..., i].T, @@ -192,7 +192,7 @@ def plot_hypersurfaces(fdata, chart=None, *, derivative=0, fig=None, axes=None, for i in range(fdata.dim_codomain): for j in range(fdata.n_samples): - if sample_labels is not None: + if sample_colors is not None: color_dict["color"] = sample_colors[j] axes[i].plot_surface(X, Y, Z[j, ..., i], @@ -290,7 +290,7 @@ def plot_scatter(fdata, chart=None, *, sample_points=None, derivative=0, for i in range(fdata.dim_codomain): for j in range(fdata.n_samples): - if sample_labels is not None: + if sample_colors is not None: color_dict["color"] = sample_colors[j] axes[i].scatter(sample_points[0], @@ -308,7 +308,7 @@ def plot_scatter(fdata, chart=None, *, sample_points=None, derivative=0, for i in range(fdata.dim_codomain): for j in range(fdata.n_samples): - if sample_labels is not None: + if sample_colors is not None: color_dict["color"] = sample_colors[j] axes[i].scatter(X, Y, diff --git a/skfda/representation/_functional_data.py b/skfda/representation/_functional_data.py index 4f66343d8..e3d0af9da 100644 --- a/skfda/representation/_functional_data.py +++ b/skfda/representation/_functional_data.py @@ -706,9 +706,9 @@ def plot(self, *args, **kwargs): """ from ..exploratory.visualization.representation import ( - plot_hypersurfaces) + plot_graph) - return plot_hypersurfaces(self, *args, **kwargs) + return plot_graph(self, *args, **kwargs) @abstractmethod def copy(self, **kwargs): From ae53434a36d29a4bc825ed0cc5e3775643f7d624 Mon Sep 17 00:00:00 2001 From: vnmabus Date: Wed, 18 Sep 2019 13:25:38 +0200 Subject: [PATCH 221/222] Correct parameter c --- skfda/exploratory/visualization/representation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skfda/exploratory/visualization/representation.py b/skfda/exploratory/visualization/representation.py index e76d38e9f..18a6c6772 100644 --- a/skfda/exploratory/visualization/representation.py +++ b/skfda/exploratory/visualization/representation.py @@ -63,7 +63,7 @@ def _get_color_info(fdata, sample_labels, label_names, label_colors, kwargs): kwargs.pop('color') elif 'c' in kwargs: - sample_colors = fdata.n_samples * [kwargs.get("color")] + sample_colors = fdata.n_samples * [kwargs.get("c")] kwargs.pop('c') else: From e0236d973b3ed36fc68e85bb1032ab964fb0e1fa Mon Sep 17 00:00:00 2001 From: vnmabus Date: Tue, 1 Oct 2019 17:49:54 +0200 Subject: [PATCH 222/222] Update version. --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 717903969..be5863417 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.3 +0.3

Yr+oe6xJ&L*=&gaDWab9-BJ)vw}?UB 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