Base.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. '''
  2. Base classes for implementing an orthonormal basis. These classes are intended
  3. to enable a convenient and uniform implementation of any number of orthonormal
  4. bases in terms of their recursion relations. At present, the only basis
  5. actually implemented is the orthonormal exponentials. However, this structure
  6. will make it convenient to implement, e.g. Legendre polynomials for an angular
  7. analysis, or any other othonormal function set that can be defined in terms
  8. of its recursion relations.
  9. This module consists of four classes:
  10. ParametricObject: A base class that implements named parameters that can be
  11. marked as fittable or fixed. Used as a base class for many
  12. FD classes.
  13. DecompFactory: Store all parameters for the basis and provide methods to
  14. create various basis objects with the correct parameters.
  15. Also provides several convenience methods for performing
  16. decompositions and construction covariance matrices.
  17. Basis: An abstract iterator object used by child classes to
  18. implement three-term recursion relations.
  19. Transform: An abstract base class to implement operations to transform
  20. between different sets of basis hyperparameters.
  21. '''
  22. import numpy as np
  23. import numexpr as ne
  24. import Tools.CacheMgr as Cache
  25. from Tools.PrintMgr import *
  26. from scipy.sparse.linalg import expm_multiply
  27. from scipy.linalg import solve
  28. from multiprocessing import Pool
  29. from abc import abstractmethod
  30. from os import environ
  31. class ParametricObject(object):
  32. '''
  33. A base class that implements a collection of named parameters.
  34. ParametricObject is _NOT_ intended for direct usage, rather it provides
  35. shared methods for its subclasses. It uses the __getitem__ / __setitem__
  36. interface, so access properties as if it were a dict.
  37. The valid parameters are defined in two class variables:
  38. _param : a list of all named parameters
  39. _fitparam : a list of which parameters are fittable
  40. These must be overridden by the subclass. When instantiating the subclass,
  41. initial values must be specified by name for ALL parameters in _param, e.g.
  42. class MyClass(ParametricObject):
  43. _param = ( "Prop1", "Prop2", "ThirdProp" )
  44. ...
  45. must be initialized like
  46. Instance = MyClass(Prop1="val1", Prop2="val2", ThirdProp="val3")
  47. , or a KeyError will be thrown.
  48. '''
  49. _param = ( )
  50. _fitparam = ( )
  51. # Update the parameters with the values from the dict 'ind'.
  52. def update(self, ind): return self.ParamVal.update(ind)
  53. # Return all parameter / value pairs.
  54. def items (self): return self.ParamVal.items()
  55. # Get parameter 'k' by name.
  56. def __getitem__ (self, k): return self.ParamVal[k]
  57. # Set parameter 'k' by name
  58. def __setitem__ (self, k, v): self.ParamVal[k] = v
  59. # Check if paramameter 'k' exists.
  60. def __contains__(self, k): return k in self.ParamVal
  61. # Get the argument dict, updated by the contents of 'kw'
  62. def _arg(self, kw):
  63. d = self.ParamVal.copy()
  64. d.update(kw)
  65. return d
  66. # kwargs must include ALL parameters listed in self._param.
  67. def __init__(self, **kwargs):
  68. self.ParamVal = { p : kwargs[p] for p in self._param }
  69. # Pretty-formatted string representation.
  70. def __str__(self):
  71. s = [ str_nl % "className" + type(self).__name__ ]
  72. s += [ str_nl % k + str(self[k]) for k in self._param if k not in self._fitparam ]
  73. s += [ str_el % k + "%.3f" % self[k] for k in self._fitparam ]
  74. return '\n'.join(s)
  75. # Format as a list of floats joined by '_'.
  76. def __format__(self, fmt):
  77. # The str(float()) construction ensures that numpy
  78. # singletons are printed nicely.
  79. s = []
  80. s += [ str(float(self[k])) for k in self._fitparam ]
  81. s += [ str(self[k]) for k in self._param if k not in self._fitparam ]
  82. return "_".join(s)
  83. class DecompFactory(ParametricObject):
  84. '''
  85. DecompFactory is a class to store the hyperparameters for an orthonormal
  86. function set and conveniently create various decomposition instances
  87. with those parameters. It is an abstract class - it should be extended
  88. by a concrete subclass that defines the correct hyperparameters and methods
  89. for the orthonormal function set.
  90. Three parameters are defined in _param:
  91. Nbasis : The total number of basis functions to consider
  92. Nxfrm : The number of basis functions to use for fast transformations
  93. during the hyperparamer scan.
  94. Ncheck : The maximum number of basis functions to consider for
  95. inclusion in the background-only set.
  96. Subclasses should extend _param with any additional parameters that they
  97. require, and set _fitparam as appropriate.
  98. Furthermore, four abstract factory methods must be implemented by the subclass:
  99. Fn (self, x, w, **kw)
  100. MxForm (self, **kw)
  101. Pri (self, **kw)
  102. Xfrm (self, **kw)
  103. See the respective docstrings of each for more detail.
  104. For an example implementation, see Tools.OrthExp.ExpDecompFactory.
  105. DecompFactory additionally provides some convenience methods for decomposing datasets
  106. and calculating covariance matrices.
  107. '''
  108. _param = ("Nbasis", "Nxfrm", "Ncheck")
  109. @abstractmethod
  110. def Fn (self, x, w, **kw):
  111. '''
  112. Return a subclass of Basis that implements an orthonormal basis function.
  113. 'x' is an ndarray with the x-values at which to evaluate the function.
  114. 'w' is an ndarray with the weights corresponding to each 'x'.
  115. These must have the same shape; if the weights are not used, just pass
  116. np.ones_like(x) as a weight.
  117. The kwargs can be used to override the hyperparameter values that are
  118. set in this DecompFactory.
  119. '''
  120. return
  121. @abstractmethod
  122. def MxForm (self, *mom, **kw):
  123. '''
  124. Return a subclass of Basis that takes in a list of moment vectors and
  125. returns the corresponding moment matrices row-by-row. Each positional
  126. argument is a single moment-vector.
  127. The kwargs can be used to override the hyperparameter values that are
  128. set in this DecompFactory.
  129. '''
  130. return
  131. @abstractmethod
  132. def Pri (self, **kw):
  133. '''
  134. Return the moments of the prior distribution moment-by-moment.
  135. The kwargs can be used to override the hyperparameter values that are
  136. set in this DecompFactory.
  137. '''
  138. return
  139. @abstractmethod
  140. def Xfrm (self, **kw):
  141. '''
  142. Return a transformation object. The transformation object takes a
  143. moment vector with the current choice of hyperparameter values and
  144. transforms it into a vector with an alternate choice of hyperparameter
  145. values. See Tools.Base.Transform for a more detailed description
  146. of the transformation object.
  147. The kwargs can be used to override the hyperparameter values that are
  148. set in this DecompFactory.
  149. '''
  150. return
  151. def MomMx (self, Mom, i, j, **kwargs):
  152. '''
  153. Return the matrix form of Mom using the fast matrix cache stored in
  154. this DecompFactory object. This method is only able to use 'Nxfrm'
  155. moments. It is intended to be used only during the hyperparameter scan.
  156. The parameters are:
  157. Mom: The moment vector to convert to matrix form.
  158. [i, j) : The range of moments to include. 'i' is included; 'j' is not
  159. Optional kwargs are:
  160. N: size of the matrix to return (N by N)
  161. out: ndarray to store the output. Otherwise, return a new allocation.
  162. '''
  163. out = kwargs.get("out", None)
  164. N = kwargs.get("N", self["Nxfrm"])
  165. j = min(j, self.TDot.shape[-1])
  166. return np.dot( self.TDot[:N,:N,i:j], Mom[i:j], out=out)
  167. def MxTensor(self, *Mom):
  168. '''
  169. Return the matrix form of each moment vector listed in the positional
  170. arguments. This uses the full MxForm machinery, so it can work on
  171. moment vectors of any length. The size of the returned covariance matrices
  172. is N by N, where N is the length of the first positional argument.
  173. '''
  174. Nb = Mom[0].size
  175. Ct = np.zeros((Nb, Nb, len(Mom)))
  176. for r in self.MxForm(*Mom, Nbasis=Nb):
  177. Ct[r.N] += r.Values().T
  178. return Ct
  179. def CovMatrix(self, Mom):
  180. '''
  181. Return the covariance matrix association with the moment vector Mom.
  182. This method works on moment vectors of any length. The returned
  183. covariance matrix is N by N, where N is the length of Mom.
  184. '''
  185. Nb = Mom.size
  186. Ct = -np.outer(Mom[:Nb], Mom[:Nb])
  187. Ct[0,0] = 1
  188. for r in self.MxForm(Mom, Nbasis=Nb):
  189. Ct[r.N] += r.Values()[0]
  190. return Ct
  191. def OpMatrix(self, N, **kwargs):
  192. '''
  193. Create and return the fast matrix cache. This calculates the N by N
  194. matrix form of each basis function, and returns the result in a form
  195. such that
  196. Mx.dot ( MomVec )
  197. results in the matrix form of MomVec. Option kwargs:
  198. M : the number of basis elements for which to calculate the N by N
  199. matrix form.
  200. Note that generally this function should not need to be used directly.
  201. One of CovMatrix, MomMx, or MxTensor probably do what you want.
  202. '''
  203. M = kwargs.get("M", N)
  204. v = np.zeros((N,N,M))
  205. F = self.MxForm( *[x for x in np.eye(N)[:M]], Nbasis=N )
  206. for x in F:
  207. v[x.N] = x.Values().T
  208. return v
  209. def Decompose(self, x, w, cksize=2**20, **kw):
  210. '''
  211. Given a dataset 'x' of values with weights 'w', compute the moments of
  212. that dataset. Both 'x' and 'w' should be one-dimensional ndarrays
  213. with the same length.
  214. The kwargs can be used to override the hyperparameter values that are
  215. set in this DecompFactory.
  216. This function recomputes the decomposition from scratch each time it is
  217. called. Normally, you want to use CachedDecompose instead - this will
  218. store the results on disk and load the stored results if that can
  219. avoid recomputing a decomposition.
  220. '''
  221. Nb = kw.pop("Nbasis", 0)
  222. Fn = self.Fn(x[:cksize+1], w[:cksize+1], Nbasis=Nb if Nb > 0 else self["Nbasis"], **kw)
  223. Mom = np.zeros( (Fn["Nbasis"],) )
  224. for i in range(0, x.size, cksize):
  225. pdot()
  226. Fn.Reinit( x[i:i+cksize+1 ], w[i:i+cksize+1] )
  227. for D in Fn: Mom[D.N] += D.Moment()
  228. return Mom
  229. @Cache.Element("{self.CacheDir}", "Decompositions", "{self}", "{2:s}-{Nbasis}.npy")
  230. def CachedDecompose(self, x, w, name, cksize=2**20, Nbasis=0, **kwargs):
  231. '''
  232. Given a dataset 'x' of values with weights 'w', compute the moments of
  233. that dataset. Both 'x' and 'w' should be one-dimensional ndarrays
  234. with the same length.
  235. The kwargs can be used to override the hyperparameter values that are
  236. set in this DecompFactory.
  237. This function stores the results on disk, and loads the stored result
  238. if the same decomposition is requested a second time. This avoids
  239. unnecessary re-decompositions, particularly if the same code is run
  240. repeatedly.
  241. '''
  242. return self.Decompose(x, w, cksize, Nbasis=Nbasis, **kwargs)
  243. def __init__(self, **kwargs):
  244. '''
  245. All parameters listed in _params are mandatory. In addition, optional
  246. arguments are:
  247. Nthread: the number of threads to use in numexpr computations.
  248. CacheDir: the directory in which to store cahced files.
  249. '''
  250. self.Nthread = kwargs.get('Nthread', 1)
  251. self.CacheDir = kwargs.get('CacheDir', "tmp")
  252. self.FDDir = environ.get('FD_DIR', ".")
  253. ParametricObject.__init__(self, **kwargs)
  254. ne.set_num_threads(self.Nthread)
  255. self.TDot = self.OpMatrix( self["Nxfrm"] )
  256. self['Factory'] = self
  257. class Basis(ParametricObject):
  258. '''
  259. A base class for orthonormal bases in terms of their three-term recursion
  260. relations. It is not intended for direct use, but instead provides several
  261. common methods for its subclasses.
  262. 'Basis' exposes an interator interface. Subclasses implement the recursion
  263. relations, and the Basis object applies them to step through the resulting
  264. values one-by-one. Three-term recursion relations take the general form:
  265. V_(n+1) = a*x*V(n) + b*V(n) + c*V(n-1)
  266. Because each term depends on the _two_ preceding terms, two base values
  267. are required. These are implemented in the 'Base0' and 'Base1' methods.
  268. 'x' is a raising operator, implemented in 'Raise'. The recursion relation
  269. itself is implemented in 'Recur'.
  270. An optional constant of integration can be included by overriding 'Xfrm'.
  271. If used, this must explicitly be incorporated in the subclass's
  272. implementation.
  273. See Tools.OrthExp for several examples of different subclasses that utilize
  274. the 'Basis' object.
  275. '''
  276. _param = ("Nbasis", )
  277. # User-facing functions.
  278. #def Values(self): return self.t[ self.N % 2 ] * np.sqrt(self.NormSq(self.N))
  279. # Return a zero ndarray of the appropriate shape and type.
  280. def Zeros (self, shape=()): return np.zeros(shape + (1,))
  281. # The zero'th value of the basis.
  282. @abstractmethod
  283. def Base0 (self, out): return None
  284. # The first value of the basis.
  285. @abstractmethod
  286. def Base1 (self, out): return None
  287. # The raising operator. This is computed on initialization and stored
  288. # in self['rz'].
  289. @abstractmethod
  290. def Raise (self, out): return None
  291. # The recursion relation. This should set 'out' to V(N+1)
  292. # given En=V(N) and Ep=V(N-1).
  293. @abstractmethod
  294. def Recur (self, N, En, Ep, out): return
  295. # (Optional) constant of integration. This is computed on initialization
  296. # and stored in self['xf'].
  297. def Xfrm (self, out): return self.Zeros()
  298. ##
  299. # Implementation of iterator interface
  300. ##
  301. def __iter__(self, **kwargs):
  302. self.N = -1
  303. for k, v in kwargs.items():
  304. self[k] = v
  305. if 'xf' in self.ParamVal and 'x' in self.ParamVal and self['x'].size == self['xf'].size:
  306. self.Xfrm (self['xf'])
  307. self.Raise (self['rz'])
  308. else:
  309. self['xf'] = self.Xfrm(None)
  310. self['rz'] = self.Raise(None)
  311. self['t'] = self.Zeros((2,))
  312. self.Base0 (self['t'][0])
  313. self.Base1 (self['t'][1])
  314. return self
  315. def next(self):
  316. next = self['t'][ (self.N + 1) % 2 ]
  317. this = self['t'][ (self.N ) % 2 ]
  318. prev = self['t'][ (self.N - 1) % 2 ]
  319. if self.N < 0: self.Base0(next)
  320. elif self.N == 0: self.Base1(next)
  321. elif self.N < self['Nbasis']: self.Recur(self.N, this, prev, next)
  322. if self.N == self['Nbasis'] - 1: raise StopIteration
  323. self.N += 1
  324. return self
  325. def __str__(self):
  326. s = [ str_nl % "className" + type(self).__name__ ]
  327. s += [ str_nl % k + str(self[k]) for k in self._param ]
  328. return '\n'.join(s)
  329. # These go with the Transform object. Must be globals for Multiprocessing
  330. gK = {}
  331. def gEle(n, m): return gK['obj'].Ele(n, m, **gK)
  332. class Transform(ParametricObject):
  333. '''
  334. A base class for implementing transformations between different choices of
  335. hyperparameters. This assumes that a transformation on each parameter can
  336. be written in the following way:
  337. x' = exp( a_p * T_p ) . x
  338. where
  339. x_p is the initial decomposition vector
  340. T_p is the infinitesimal transformation matrix for parameter p
  341. a_p is the amount to transform parameter p
  342. Not all transformations can be written in this way! However, those than can
  343. be are convenient and tractable for numerical computation. Since T_p is
  344. presumed to be a constant, independent of the choice of hyperparameters,
  345. it can be computed exactly once and then cached on disk. Since this is
  346. typically an expensive computation, this is ideal.
  347. The 'Transform' object provides some framework to calculate T for each
  348. hyperparameter and cache the result. It also supplies methods to use
  349. these transformation matrices on decomposition vectors to transform them
  350. from one set of hyperparameters to another.
  351. The _param class member must specify each of the hyperparameters (by name)
  352. for which a transformation matrix will be computed. Each specified
  353. hyperparameter must have a corresponding method of the same name that
  354. return T_p. An example implementation would look something like:
  355. _param = ("HyperA", "HyperB" )
  356. def HyperA(self):
  357. ...
  358. def HyperB(self):
  359. ...
  360. In most cases, the matrix is computed more easily in some non-orthonormal
  361. basis and can then be transformed into the orthonormal basis. In this
  362. case,
  363. T_p = O . U_p . D
  364. where O is the orthonormal functions expressed in the non-orthonormal
  365. basis and D is the derivatives of the orthonormal functions expressed in
  366. the non-orthonormal basis.
  367. The @OrthogonalizeHankel decorator is provided to automate this in the
  368. case that U_p is a Hankel matrix (i.e. all antidiagonals are constant). To
  369. make this work, the subclass must implement two functions
  370. def CoeffOrth(self, n): return the n by n matrix O[n,m] / O[n,m-1]
  371. def CoeffDeriv(self, n): return the n by n matrix D[n,m] / D[m,m-1]
  372. and implement the the transformations like:
  373. @Base.Transform.OrthogonalizeHankel
  374. def HyperA(self, n): return the value of U_p[n, n]
  375. CoeffOrth and CoeffDeriv return _ratios_ of the columns in O and D, as this
  376. generally has a much simplier expression than the columns directly. This
  377. in turns greatly increases evaluation speed when the underlying type is
  378. Fraction.
  379. Then OrthogonalizeHankel will handle the matrix multiplications using a
  380. multiprocessing pool. This is overkill if the O, D and U_p are simple
  381. types like an int or a float. However, it is sometimes necessary for
  382. numerical reasons to use slow but accurate types like Fraction, and when
  383. the matrices are large this can become very slow. In this case, the
  384. multiprocessing provides a substantial performance improvement.
  385. The results of OrthogonalizeHankel are cached on disk, so in any case these
  386. computations need only be performed once. This cache is located in
  387. <FD_DIR>/data
  388. See Tools.OrthExp.ExpDecompTransform for an example implementation.
  389. '''
  390. _param = ( )
  391. def __call__(self, *vec, **kwargs):
  392. '''
  393. Transform a list of moments vectors from the current hyperparameters
  394. into an alternate choice of hyperparameters. The current parameters
  395. are specified by the values stored in this object, and the new
  396. parameters are specified in kwargs. Each positional argument
  397. should be an ndarray moment vector.
  398. '''
  399. for p in self._param:
  400. if p not in kwargs:
  401. kwargs[p] = self.ParamVal[p]
  402. inv = kwargs.get("inv", False)
  403. ini = kwargs if inv else self.ParamVal
  404. fin = self.ParamVal if inv else kwargs
  405. ret = np.stack(vec, axis=1)[:self["Nxfrm"]]
  406. arg = self.XfPar(fin, ini)
  407. mx = sum( arg[p] * self.KMx[p].T for p in self._param )
  408. ret = expm_multiply( mx, ret )
  409. return tuple(ret.T)
  410. @Cache.AtomicElement("{self.Factory.FDDir}", "data", "ele-cache-{name:s}", "{0:d}-{1:d}.json")
  411. def Ele(self, n, m, **kwargs):
  412. '''
  413. Compute element (n, m) of self.O . H . self.D where H is a Hankel matrix.
  414. H is specified by passing it's diagonal as the kwarg 'h'.
  415. '''
  416. h = kwargs.get("h")
  417. v = np.outer(self.O[n][::-1], self.D[m])
  418. s = [ np.trace(v, offset=k) for k in range(-v.shape[0]+1, v.shape[1]) ]
  419. return np.dot(h[:len(s)], s) * self.Norm(n, m)
  420. @staticmethod
  421. def OrthogonalizeHankel(func):
  422. '''
  423. Decorator to create N x N infinitesimal transformation matrices from a
  424. wrapped Hankel matrix. This function takes two parameters:
  425. name : the name of the corresponding hyperparameter. Used to cache
  426. the matrix in a unique file.
  427. N : the size of the tranformation matrix.
  428. The wrapped function must take a single argument, like this:
  429. @Base.Transform.OrthogonalizeHankel
  430. def TheWrappedFunction(self, n):
  431. TheWrappedFunction should take a number n and return the value of the
  432. n'th diagonal element (i.e. H[n, n]).
  433. See Tools.OrthExp.ExpDecompTransform for an example.
  434. '''
  435. @Cache.Element("{self.Factory.FDDir}", "data", "xfrm-cache-{0:s}-{1:d}.npy")
  436. def wrap(self, name, N):
  437. pini(name + " xfrm moment")
  438. global gK
  439. gK = {
  440. 'h' : [ pdot(func(self, k)) for k in range(2*N-1) ],
  441. 'name': name,
  442. 'obj' : self
  443. }
  444. pend()
  445. pini(name + " xfrm matrix", interval=N)
  446. p = Pool( self.Factory.Nthread )
  447. r = [ p.apply_async(gEle, (n, m), callback=pdot) for n in range(N) for m in range(N) ]
  448. p.close()
  449. r = np.array([ x.get() for x in r]).reshape((N, N))
  450. pend()
  451. return r
  452. return wrap
  453. # Calculate infinitesimal transformations
  454. def __init__(self, **kwargs):
  455. '''
  456. All parameters listed in _params are mandatory. Additional mandatory
  457. parameters are:
  458. Nxfrm: The size of the transformation matrices (Nxfrm x Nxfrm)
  459. Factory: The parent Factory object.
  460. '''
  461. ParametricObject.__init__(self, **kwargs)
  462. self["Nxfrm"] = kwargs["Nxfrm"]
  463. self.Factory = kwargs["Factory"]
  464. self.O = [ self.CoeffOrth (n) for n in range(self["Nxfrm"]) ]
  465. self.D = [ self.CoeffDeriv(n) for n in range(self["Nxfrm"]) ]
  466. self.KMx = { n : getattr(self, n)(n, self["Nxfrm"]) for n in self._param }