Source code for omegaml.mixins.mdf.apply

   1import hashlib
   2import json
   3from itertools import product
   4from uuid import uuid4
   5
   6import pandas as pd
   7
   8from omegaml.documents import make_QueryCache
   9from omegaml.mdataframe import MDataFrame, MSeries
  10from omegaml.store import qops
  11from omegaml.store.filtered import FilteredCollection
  12from omegaml.util import extend_instance, make_tuple
  13
  14
  15class ApplyMixin:
  16    """
  17    Implements the apply() mixin supporting arbitrary functions to build aggregation pipelines
  18
  19    Note that .apply() does not execute immediately. Instead it builds an aggregation pipeline
  20    that is executed on MDataFrame.value. Note that .apply() calls cannot be cascaded yet, i.e.
  21    a later .apply() will override a previous.apply().
  22
  23    See ApplyContext for usage examples.
  24    """
  25
  26    def __init__(self, *args, **kwargs):
  27        super().__init__(*args, **kwargs)
  28        self._init_mixin(*args, **kwargs)
  29
  30    def _init_mixin(self, *args, **kwargs):
  31        self.apply_fn = kwargs.get('apply_fn', None)
  32        # set to True if the pipeline is a facet operation
  33        self.is_from_facet = kwargs.get('is_from_facet', False)
  34        # index columns
  35        self.index_columns = kwargs.get('index_columns', [])
  36        # db alias
  37        self._db_alias = kwargs.get('db_alias', self._ensure_db_connection())
  38        # cache used on persist()
  39        self.cache = kwargs.get('cache', ApplyCache(self._db_alias))
  40
  41    def _ensure_db_connection(self):
  42        # must import _dbs, _connections locally to ensure mongoshim has been applied
  43        from mongoengine.connection import _connections, _dbs
  44
  45        seek_db = self.collection.database
  46        for alias, db in _dbs.items():
  47            if db is seek_db:
  48                self._db_alias = alias
  49                break
  50        else:
  51            # fake connection register
  52            alias = self._db_alias = f'omega-{uuid4().hex}'
  53            _connections[alias] = seek_db.client
  54            _dbs[alias] = seek_db
  55        return self._db_alias
  56
  57    def nocache(self):
  58        self.cache = None
  59        return self
  60
  61    def reset_cache(self, full=False):
  62        """
  63        Reset the apply cache
  64
  65        :param full: if True will reset all caches for the collection, if False will only remove
  66            the cache for the specific .apply operations
  67        :return:
  68        """
  69        QueryCache = make_QueryCache(db_alias=self._db_alias)
  70        if full:
  71            QueryCache.objects.filter(value__collection=self.collection.name).delete()
  72        else:
  73            pipeline = self._build_pipeline()
  74            key = self._make_cache_key(self.collection, pipeline)
  75            QueryCache.objects.filter(key=key).delete()
  76        return self
  77
  78    def _make_cache_key(self, collection, pipeline):
  79        # remove random output value
  80        if '$out' in pipeline[-1] and pipeline[-1]['$out'].startswith('cache'):
  81            pipeline = list(pipeline)[:-1]
  82        spipeline = json.dumps(pipeline, sort_keys=True)
  83        data = f'{collection.name}_{spipeline}'.encode()
  84        # SEC: CWE-916
  85        # - status: wontfix
  86        # - reason: hashcode is used purely for name resolution, not a security function
  87        key = hashlib.md5(data).hexdigest()
  88        return key
  89
  90    def _getcopy_kwargs(self, **kwargs):
  91        kwargs = super()._getcopy_kwargs(**kwargs)
  92        kwargs.update(
  93            is_from_facet=self.is_from_facet,
  94            index_columns=self.index_columns,
  95            cache=self.cache,
  96            apply_fn=self.apply_fn,
  97        )
  98        return kwargs
  99
 100    def noapply(self):
 101        self.apply_fn = None
 102        return self
 103
 104    def apply(self, fn, inplace=False, preparefn=None):
 105        if inplace:
 106            obj = self
 107        else:
 108            kwargs = self._getcopy_kwargs()
 109            kwargs.update(preparefn=preparefn)
 110            if isinstance(self, MSeries):
 111                obj = MSeries(self.collection, **kwargs)
 112            else:
 113                obj = MDataFrame(self.collection, **kwargs)
 114        obj.apply_fn = fn
 115        return obj
 116
 117    def persist(self):
 118        """
 119        Execute and store results in cache
 120
 121        Any pipeline of the same operations, in the same order, on
 122        the same collection will return the same result.
 123        """
 124        # generate a cache key
 125        pipeline = self._build_pipeline()
 126        key = self._make_cache_key(self.collection, pipeline)
 127        outname = f'cache_{uuid4().hex}'
 128        value = {
 129            'collection': self.collection.name,
 130            'result': outname,
 131        }
 132        # do usual processing, store result
 133        # -- note we pass pipeline to avoid processing iterators twice
 134        pipeline.append(
 135            {
 136                '$out': outname,
 137            },
 138        )
 139        cursor = self._get_cursor(pipeline=pipeline, use_cache=False)
 140        # consume cursor to store output (via $out)
 141        for v in cursor:
 142            pass
 143        # set cache
 144        self.cache.set(key, value)
 145        return key
 146
 147    def set_index(self, columns):
 148        self.index_columns = make_tuple(columns)
 149        return self
 150
 151    def inspect(self, explain=False, *args, **kwargs):
 152        if self.apply_fn:
 153            details = {
 154                'pipeline': self._build_pipeline(),
 155            }
 156            if explain:
 157                details.update(self.__dict__)
 158            return details
 159        return super().inspect(*args, explain=explain, **kwargs)
 160
 161    def _execute(self):
 162        ctx = ApplyContext(self, columns=self.columns)
 163        try:
 164            result = self.apply_fn(ctx)
 165        except Exception as e:
 166            msg = [repr(stage) for stage in ctx.stages] + [repr(e)]
 167            raise RuntimeError(msg)
 168        if result is None or isinstance(result, ApplyContext):
 169            result = result or ctx
 170            self.index_columns = self.index_columns or result.index_columns
 171            return result
 172        elif isinstance(result, list):
 173            return result
 174        elif isinstance(result, dict):
 175            # expect a mapping of col=ApplyContext each with its own list of stages
 176            # -- build a combined context by adding each expression
 177            #    this ensures any multi-stage projections are carried forward
 178            facets = {}
 179            for col, expr in result.items():
 180                if isinstance(expr, ApplyContext):
 181                    facets[col] = list(expr)
 182                    project = {
 183                        '$project': {
 184                            col: '$' + expr.columns[0],
 185                        }
 186                    }
 187                    facets[col].append(project)
 188                else:
 189                    facets[col] = expr
 190            facet = {
 191                '$facet': facets,
 192            }
 193            self.is_from_facet = True
 194            return [facet]
 195        raise ValueError(f'Cannot build pipeline from apply result of type {type(result)}')
 196
 197    def _build_pipeline(self):
 198        pipeline = []
 199        stages = self._execute()
 200        pipeline.extend(stages)
 201        self._amend_pipeline(pipeline)
 202        return pipeline
 203
 204    def _amend_pipeline(self, pipeline):
 205        """amend pipeline with default ops on coll.aggregate() calls"""
 206        if self.sort_order:
 207            sort = qops.SORT(**dict(qops.make_sortkey(self.sort_order)))
 208            pipeline.append(sort)
 209        return pipeline
 210
 211    def _get_cached_cursor(self, pipeline=None, use_cache=True):
 212        pipeline = pipeline or self._build_pipeline()
 213        if use_cache and self.cache:
 214            key = self._make_cache_key(self.collection, pipeline)
 215            entry = self.cache.get(key)
 216            if entry is not None:
 217                # read result
 218                outname = entry.value['result']
 219                return self.collection.database[outname].find()
 220
 221    def _get_cursor(self, pipeline=None, use_cache=True):
 222        # for apply functions, call the apply function, expecting a pipeline in return
 223        if self.apply_fn:
 224            pipeline = pipeline or self._build_pipeline()
 225            cursor = self._get_cached_cursor(pipeline=pipeline, use_cache=use_cache)
 226            if cursor is None:
 227                filter_criteria = self._get_filter_criteria()
 228                cursor = FilteredCollection(self.collection).aggregate(
 229                    pipeline, filter=filter_criteria, allowDiskUse=True
 230                )
 231        else:
 232            cursor = super()._get_cursor()
 233        return cursor
 234
 235    def _get_dataframe_from_cursor(self, cursor):
 236        df = super()._get_dataframe_from_cursor(cursor)
 237        if self.is_from_facet:
 238            # if this was from a facet pipeline (i.e. multi-column mapping), combine
 239            # $facet returns one document for each stage.
 240            frames = []
 241            for col in df.columns:
 242                coldf = pd.DataFrame(df[col].iloc[0]).set_index('_id')
 243                frames.append(coldf)
 244            df = pd.concat(frames, axis=1).reset_index()
 245            df = self._restore_dataframe_proper(df)
 246        # TODO write a unit test for this condition
 247        if self.index_columns and all(col in df.columns for col in self.index_columns):
 248            df.set_index(list(self.index_columns), inplace=True)
 249        return df
 250
 251
[docs] 252class ApplyContext: 253 """ 254 Enable apply functions 255 256 .apply(fn) will call fn(ctx) where ctx is an ApplyContext. 257 The context supports methods to apply functions in a Pandas-style apply manner. ApplyContext is extensible 258 by adding an extension class to defaults.OMEGA_MDF_APPLY_MIXINS. 259 260 Note that unlike a Pandas DataFrame, ApplyContext does not itself contain any data. 261 Rather it is part of an expression tree, i.e. the aggregation pipeline. Thus any 262 expressions applied are translated into operations on the expression tree. The expression 263 tree is evaluated on MDataFrame.value, at which point the ApplyContext nor the function 264 that created it are active. 265 266 Examples:: 267 268 mdf.apply(lambda v: v * 5 ) => multiply every column in dataframe 269 mdf.apply(lambda v: v['foo'].dt.week) => get week of date for column foo 270 mdf.apply(lambda v: dict(a=v['foo'].dt.week, 271 b=v['bar'] * 5) => run multiple pipelines and get results 272 273 The callable passed to apply can be any function. It can either return None, 274 the context passed in or a list of pipeline stages. 275 276 # apply any of the below functions 277 mdf.apply(customfn) 278 279 # same as lambda v: v.dt.week 280 def customfn(ctx): 281 return ctx.dt.week 282 283 # simple pipeline 284 def customfn(ctx): 285 ctx.project(x={'$multiply: ['$x', 5]},) 286 ctx.project(y={'$divide: ['$x', 2]},) 287 288 # complex pipeline 289 def customfn(ctx): 290 return [ 291 { '$match': ... }, 292 { '$project': ... }, 293 ] 294 """ 295 296 def __init__(self, caller, columns=None, index=None): 297 self.caller = caller 298 self.columns = columns 299 self.index_columns = index or [] 300 self.computed = [] 301 self.stages = [] 302 self.expressions = [] 303 self._apply_mixins() 304 305 def _apply_mixins(self): 306 """ 307 apply mixins in defaults.OMEGA_MDF_APPLY_MIXINS 308 """ 309 from omegaml import settings 310 311 defaults = settings() 312 for mixin, applyto in defaults.OMEGA_MDF_APPLY_MIXINS: 313 if any(v in self.caller._applyto for v in applyto.split(',')): 314 extend_instance(self, mixin) 315 316 def __iter__(self): 317 # return pipeline stages 318 for stage in self.stages: 319 if isinstance(stage, ApplyContext): 320 for sub_stage in stage: 321 yield sub_stage 322 else: 323 yield stage 324 325 def __getitem__(self, sel): 326 """ 327 return a stage subset on a column 328 """ 329 subctx = ApplyContext(self.caller, columns=make_tuple(sel), index=self.index_columns) 330 self.add(subctx) 331 return subctx 332 333 def __setitem__(self, sel, val): 334 """ 335 add a projection to a sub context 336 337 ctx['col'] = value-expression 338 """ 339 mapping = {col: v for (col, v) in zip(make_tuple(sel), make_tuple(val))} 340 self.project(mapping) 341 342 def __repr__(self): 343 return f'ApplyContext(stages={self.stages}, expressions={self.expressions},)' 344 345 def add(self, stage): 346 """ 347 Add a processing stage to the pipeline 348 349 see https://docs.mongodb.com/manual/meta/aggregation-quick-reference/ 350 """ 351 self.stages.append(stage) 352 return self 353 354 def project_keeper_columns(self): 355 # keep index, computed 356 index = {col: '$' + col for col in self.index_columns} 357 computed = {col: '$' + col for col in self.computed} 358 keep = {} 359 keep.update(index) 360 keep.update(computed) 361 project = self.project(keep, keep=True) 362 return project 363 364 def _getLastStageKind(self, kind): 365 # see if there is already an open projection stage 366 for stage in self.stages[::-1]: 367 if kind in stage: 368 return stage 369 370 def _getProjection(self, append=False): 371 stage = self._getLastStageKind('$project') 372 if stage is None or append: 373 stage = { 374 '$project': { 375 '_id': 1, 376 } 377 } 378 self.stages.append(stage) 379 return stage 380 381 def _getGroupBy(self, by=None, append=False): 382 stage = self._getLastStageKind('$group') 383 if stage and stage['$group']['_id'] != by and by != '$$last': 384 # if a different groupby criteria, add a new one 385 stage = None 386 if stage is None and by == '$$last': 387 by = None 388 if stage is None or append: 389 stage = { 390 '$group': { 391 '_id': by, 392 }, 393 } 394 self.stages.append(stage) 395 return stage 396 397 def groupby(self, by, expr=None, append=None, **kwargs): 398 """ 399 add a groupby accumulation using $group 400 401 :param by: the groupby columns, if provided as a list will be transformed 402 :param expr: 403 :param append: 404 :param kwargs: 405 :return: 406 407 """ 408 by = make_tuple(by) 409 self.index_columns = self.index_columns + list(by) 410 # define groupby 411 by = {col: '$' + col for col in by} 412 stage = self._getGroupBy(by) 413 groupby = stage['$group'] 414 # add acccumulators 415 expr = expr or {col: colExpr for col, colExpr in kwargs.items()} 416 groupby.update(expr) 417 # add a projection to extract groupby values 418 extractId = {col: '$_id.' + col for col in by} 419 # add a projection to keep accumulator columns 420 keepCols = dict.fromkeys(expr, 1) 421 keepCols.update(extractId) 422 self.project(keepCols, append=True) 423 # sort by groupby keys 424 self.add( 425 { 426 '$sort': dict.fromkeys(by, 1), 427 }, 428 ) 429 return self 430 431 def project(self, expr=None, append=False, keep=False, **kwargs): 432 """ 433 add a projection using $project 434 435 :param expr: the column-operator mapping 436 :param append: if True add a $project stage, otherwise add to existing 437 :param kwargs: if expr is None, the column-operator mapping as kwargs 438 :return: ApplyContext 439 440 """ 441 # get last $project stage in pipeline 442 stage = self._getProjection(append=append) 443 expr = expr or kwargs 444 self.expressions.append(expr) 445 for k, v in expr.items(): 446 # only append to stage if no other column projection was there 447 project = stage.get('$project') 448 if k not in project: 449 project.update( 450 {k: v}, 451 ) 452 elif not keep: 453 # if a column is already projected, add a new projection stage 454 stage = self._getProjection(append=True) 455 project = stage.get('$project') 456 project.update( 457 {k: v}, 458 ) 459 return self
460 461
[docs] 462class ApplyArithmetics: 463 """ 464 Math operators for ApplyContext 465 466 * :code:`__mul__` (*) 467 * :code:`__add__` (+) 468 * :code:`__sub__` (-) 469 * :code:`__div__` (/) 470 * :code:`__floordiv__` (//) 471 * :code:`__mod__` (%) 472 * :code:`__pow__` (pow) 473 * :code:`__ceil__` (ceil) 474 * :code:`__floor__` (floor) 475 * :code:`__trunc__` (trunc) 476 * :code:`__abs__` (abs) 477 * :code:`sqrt` (math.sqrt) 478 479 """ 480 481 def __arithmop__(op, wrap_op=None): 482 """ 483 return a pipeline $project stage math operator as 484 { col: 485 { '$operator': [ values, ...] } 486 ... 487 } 488 489 If wrap_op is specified, will wrap the $operator clause as 490 { col: 491 { '$wrap_op': { '$operator': [ values, ...] } }0 492 ... 493 } 494 """ 495 496 def inner(self, other): 497 terms = [] 498 for term in make_tuple(other): 499 if isinstance(term, str): 500 term = '$' + term 501 terms.append(term) 502 503 def wrap(expr): 504 if wrap_op is not None: 505 expr = {wrap_op: expr} 506 return expr 507 508 mapping = { 509 col: wrap( 510 {op: ['$' + col] + terms}, 511 ) 512 for col in self.columns 513 } 514 keepCols = {col: '$' + col for col in self.index_columns} 515 mapping.update(keepCols) 516 self.project(mapping) 517 return self 518 519 return inner 520 521 #: multiply 522 __mul__ = __arithmop__('$multiply') 523 #: add 524 __add__ = __arithmop__('$add') 525 #: subtract 526 __sub__ = __arithmop__('$subtract') 527 #: divide 528 __div__ = __arithmop__('$divide') 529 __truediv__ = __arithmop__('$divide') 530 #: divide integer 531 __floordiv__ = __arithmop__('$divide', wrap_op='$floor') 532 #: modulo (%) 533 __mod__ = __arithmop__('$mod') 534 #: pow 535 __pow_ = __arithmop__('$pow') 536 #: ceil 537 __ceil__ = __arithmop__('$ceil') 538 #: floor 539 __floor__ = __arithmop__('$floor') 540 #: truncate 541 __trunc__ = __arithmop__('$trunc') 542 #: absolute 543 __abs__ = __arithmop__('$abs') 544 #: square root 545 sqrt = __arithmop__('sqrt')
546 547
[docs] 548class ApplyDateTime: 549 """ 550 Datetime operators for ApplyContext 551 """ 552 553 @property 554 def dt(self): 555 return self 556 557 def __dtop__(op): 558 """ 559 return a datetime $project operator as 560 { col: 561 { '$operator': '$col} } 562 ... 563 } 564 """ 565 566 def inner(self, columns=None): 567 columns = make_tuple(columns or self.columns) 568 mapping = {col: {op: '$' + col} for col in columns} 569 self.project(mapping) 570 return self 571 572 inner.__doc__ = op.replace('$', '') 573 return inner 574 575 # mongodb mappings 576 _year = __dtop__('$year') 577 _month = __dtop__('$month') 578 _week = __dtop__('$week') 579 _dayOfWeek = __dtop__('$dayOfWeek') 580 _dayOfMonth = __dtop__('$dayOfMonth') 581 _dayOfYear = __dtop__('$dayOfYear') 582 _hour = __dtop__('$hour') 583 _minute = __dtop__('$minute') 584 _second = __dtop__('$second') 585 _millisecond = __dtop__('$millisecond') 586 _isoDayOfWeek = __dtop__('$isoDayOfWeek') 587 _isoWeek = __dtop__('$isoWeek') 588 _isoWeekYear = __dtop__('$isoWeekYear') 589 590 # .dt accessor convenience similar to pandas.dt 591 # see https://pandas.pydata.org/pandas-docs/stable/api.html#datetimelike-properties 592 year = property(_year) 593 month = property(_month) 594 day = property(_dayOfMonth) 595 hour = property(_hour) 596 minute = property(_minute) 597 second = property(_second) 598 millisecond = property(_millisecond) 599 week = property(_isoWeek) 600 dayofyear = property(_dayOfYear) 601 dayofweek = property(_dayOfWeek)
602 603
[docs] 604class ApplyString: 605 """ 606 String operators 607 """ 608 609 @property 610 def str(self): 611 return self 612 613 def __strexpr__(op, unwind=False, base=None, max_terms=None): 614 """ 615 return a pipeline $project string operator as 616 { col: 617 { '$operator': [ values, ...] } 618 ... 619 } 620 """ 621 622 def inner(self, other, *args): 623 # get all values passed and build terms from them 624 values = list(make_tuple(other) + args) 625 terms = [] 626 for term in values: 627 if isinstance(term, str): 628 # if the term is a column name, add as a column name 629 if term in self.columns: 630 term = '$' + term 631 # allow to specify values explicitely by $$<value> => <value> 632 term = term.replace('$$', '') 633 terms.append(term) 634 # limit number of terms if requested 635 if max_terms: 636 terms = terms[:max_terms] 637 # add projection of output columns to operator 638 mapping = { 639 col: { 640 op: terms if base is None else ['$' + col] + terms, 641 } 642 for col in self.columns 643 } 644 self.project(mapping) 645 # unwind all columns if requested 646 if unwind: 647 exprs = [{'$unwind': {'path': '$' + col}} for col in self.columns] 648 self.stages.extend(exprs) 649 return self 650 651 inner.__doc__ = op.replace('$', '') 652 return inner 653 654 def __strunary__(op, unwind=False): 655 """ 656 return a datetime $project operator as 657 { col: 658 { '$operator': '$col} } 659 ... 660 } 661 """ 662 663 def inner(self, columns=None): 664 columns = make_tuple(columns or self.columns) 665 mapping = {col: {op: '$' + col} for col in columns} 666 self.project(mapping) 667 if unwind: 668 self.stages.append( 669 { 670 '$unwind': {''}, 671 }, 672 ) 673 return self 674 675 inner.__doc__ = op.replace('$', '') 676 677 return inner 678 679 def isequal(self, other): 680 self.strcasecmp(other) 681 # strcasecmp returns 0 for equality, 1 and -1 for greater/less than 682 # https://docs.mongodb.com/manual/reference/operator/aggregation/strcasecmp/ 683 mapping = { 684 col: { 685 '$cond': { 686 'if': {'$eq': ['$' + col, 0]}, 687 'then': True, 688 'else': False, 689 }, 690 } 691 for col in self.columns 692 } 693 self.project(mapping) 694 695 concat = __strexpr__('$concat', base=True) 696 split = __strexpr__('$split', unwind=True, base=True, max_terms=2) 697 usplit = __strexpr__('$split', unwind=False, base=True, max_terms=2) 698 upper = __strunary__('$toUpper') 699 lower = __strunary__('$toLower') 700 substr = __strexpr__('$substr', base=True) 701 strcasecmp = __strexpr__('$strcasecmp', base=True) 702 len = __strunary__('$strLenBytes') 703 index = __strexpr__('$indexOfBytes', base=True)
704 705
[docs] 706class ApplyAccumulators: 707 def agg(self, map=None, **kwargs): 708 stage = self._getGroupBy(by='$$last') 709 specs = map or kwargs 710 for col, colExpr in specs.items(): 711 if isinstance(colExpr, dict): 712 # specify an arbitrary expression 713 groupby = stage['$group'] 714 groupby[col] = colExpr 715 elif isinstance(colExpr, str): 716 # specify some known operator 717 if hasattr(self, colExpr): 718 method = getattr(self, colExpr) 719 method(col) 720 else: 721 raise SyntaxError(f'{colExpr} is not known') 722 elif isinstance(colExpr, (tuple, list)): 723 # specify a list of some known operators 724 for statExpr in colExpr: 725 if hasattr(self, statExpr): 726 method = getattr(self, statExpr) 727 method(col) 728 else: 729 raise SyntaxError(f'{statExpr} is not known') 730 elif callable(colExpr): 731 # specify a callable that returns an expression 732 groupby = stage['$group'] 733 groupby[col] = colExpr(col) 734 else: 735 SyntaxError(f'{colExpr} on column {col} is unknown or invalid') 736 return self 737 738 def __statop__(op, opname=None): 739 opname = opname or op.replace('$', '') 740 741 def inner(self, columns=None): 742 columns = make_tuple(columns or self.columns) 743 stage = self._getGroupBy(by='$$last') 744 groupby = stage['$group'] 745 groupby.update( 746 { 747 f'{col}_{opname}': { 748 op: '$' + col, 749 } 750 for col in columns 751 }, 752 ) 753 self.computed.extend(groupby.keys()) 754 self.project_keeper_columns() 755 return self 756 757 return inner 758 759 sum = __statop__('$sum') 760 avg = __statop__('$avg') 761 mean = __statop__('$avg') 762 min = __statop__('$min') 763 max = __statop__('$max') 764 std = __statop__('$stdDevSamp', 'std')
765 766 767class ApplyCache: 768 """ 769 A Cache that works on collections and pipelines 770 """ 771 772 def __init__(self, db_alias): 773 self._db_alias = db_alias 774 775 def set(self, key, value): 776 # https://stackoverflow.com/a/22003440/890242 777 QueryCache = make_QueryCache(self._db_alias) 778 QueryCache.objects(key=key).update_one( 779 set__key=f"{key}", 780 set__value=value, 781 upsert=True, 782 ) 783 784 def get(self, key): 785 QueryCache = make_QueryCache(self._db_alias) 786 try: 787 result = QueryCache.objects.get(key=key) 788 except: 789 result = None 790 return result 791 792 793class ApplyStatistics: 794 def quantile(self, q=0.5): 795 def preparefn(val): 796 return val.pivot(columns='var', index='percentile', values='value') 797 798 return self.apply(self._percentile(q), preparefn=preparefn) 799 800 def cov(self): 801 def preparefn(val): 802 val = val.pivot(columns='y', index='x', values='cov') 803 val.index.name = None 804 val.columns.name = None 805 return val 806 807 return self.apply(self._covariance, preparefn=preparefn) 808 809 def corr(self): 810 def preparefn(val): 811 val = val.pivot(columns='y', index='x', values='rho') 812 val.index.name = None 813 val.columns.name = None 814 return val 815 816 return self.apply(self._pearson, preparefn=preparefn) 817 818 def _covariance(self, ctx): 819 # this works 820 # source http://ci.columbia.edu/ci/premba_test/c0331/s7/s7_5.html 821 facets = {} 822 means = {} 823 unwinds = [] 824 count = len(ctx.caller.noapply()) - 1 825 for x, y in product(ctx.columns, ctx.columns): 826 xcol = '$' + x 827 ycol = '$' + y 828 # only calculate the same column's mean once 829 if xcol not in means: 830 means[xcol] = ctx.caller[x].noapply().mean().values[0, 0] 831 if ycol not in means: 832 means[ycol] = ctx.caller[y].noapply().mean().values[0, 0] 833 sumands = { 834 xcol: { 835 '$subtract': [xcol, means[xcol]], 836 }, 837 ycol: {'$subtract': [ycol, means[ycol]]}, 838 } 839 multiply = { 840 '$multiply': [sumands[xcol], sumands[ycol]], 841 } 842 agg = { 843 '$group': { 844 '_id': None, 845 'value': { 846 '$sum': multiply, 847 }, 848 } 849 } 850 project = { 851 '$project': { 852 'cov': {'$divide': ['$value', count]}, 853 'x': x, 854 'y': y, 855 } 856 } 857 pipeline = [agg, project] 858 outcol = f'{x}_{y}' 859 facets[outcol] = pipeline 860 unwinds.append( 861 {'$unwind': '$' + outcol}, 862 ) 863 facet = {'$facet': facets} 864 expand = [ 865 { 866 '$project': { 867 'value': {'$objectToArray': '$$CURRENT'}, 868 } 869 }, 870 {'$unwind': '$value'}, 871 { 872 '$replaceRoot': {'newRoot': '$value.v'}, 873 }, 874 ] 875 return [facet, *unwinds, *expand] 876 877 def _pearson(self, ctx): 878 # this works 879 # source http://ilearnasigoalong.blogspot.ch/2017/10/calculating-correlation-inside-mongodb.html 880 facets = {} 881 unwinds = [] 882 for x, y in product(ctx.columns, ctx.columns): 883 xcol = '$' + x 884 ycol = '$' + y 885 sumcolumns = { 886 '$group': { 887 '_id': None, 888 'count': {'$sum': 1}, 889 'sumx': {'$sum': xcol}, 890 'sumy': {'$sum': ycol}, 891 'sumxsquared': {'$sum': {'$multiply': [xcol, xcol]}}, 892 'sumysquared': {'$sum': {'$multiply': [ycol, ycol]}}, 893 'sumxy': {'$sum': {'$multiply': [xcol, ycol]}}, 894 } 895 } 896 897 multiply_sumx_sumy = { 898 '$multiply': ["$sumx", "$sumy"], 899 } 900 multiply_sumxy_count = { 901 '$multiply': ["$sumxy", "$count"], 902 } 903 partone = { 904 '$subtract': [multiply_sumxy_count, multiply_sumx_sumy], 905 } 906 907 multiply_sumxsquared_count = { 908 '$multiply': ["$sumxsquared", "$count"], 909 } 910 sumx_squared = { 911 '$multiply': ["$sumx", "$sumx"], 912 } 913 subparttwo = { 914 '$subtract': [multiply_sumxsquared_count, sumx_squared], 915 } 916 917 multiply_sumysquared_count = { 918 '$multiply': ["$sumysquared", "$count"], 919 } 920 sumy_squared = { 921 '$multiply': ["$sumy", "$sumy"], 922 } 923 subpartthree = { 924 '$subtract': [multiply_sumysquared_count, sumy_squared], 925 } 926 927 parttwo = { 928 '$sqrt': { 929 '$multiply': [subparttwo, subpartthree], 930 }, 931 } 932 933 rho = { 934 '$project': { 935 'rho': { 936 '$divide': [partone, parttwo], 937 }, 938 'x': x, 939 'y': y, 940 } 941 } 942 pipeline = [sumcolumns, rho] 943 outcol = f'{x}_{y}' 944 facets[outcol] = pipeline 945 unwinds.append( 946 {'$unwind': '$' + outcol}, 947 ) 948 facet = {'$facet': facets} 949 expand = [ 950 { 951 '$project': { 952 'value': {'$objectToArray': '$$CURRENT'}, 953 } 954 }, 955 {'$unwind': '$value'}, 956 { 957 '$replaceRoot': {'newRoot': '$value.v'}, 958 }, 959 ] 960 return [facet, *unwinds, *expand] 961 962 def _percentile(self, pctls=None): 963 """ 964 calculate percentiles for all columns 965 """ 966 pctls = pctls or [0.25, 0.5, 0.75] 967 if not isinstance(pctls, (list, tuple)): 968 pctls = [pctls] 969 970 def calc(col, p, outcol): 971 # sort values 972 sort = { 973 '$sort': {col: 1}, 974 } 975 # group/push to get an array of all values 976 group = { 977 '$group': { 978 '_id': col, 979 'values': { 980 '$push': "$" + col, 981 }, 982 }, 983 } 984 # find value at requested percentile 985 perc = { 986 '$arrayElemAt': [ 987 '$values', 988 { 989 '$floor': { 990 '$multiply': [ 991 { 992 '$size': '$values', 993 }, 994 p, 995 ] 996 } 997 }, 998 ], 999 } 1000 # map percentile value to output column 1001 project = { 1002 '$project': { 1003 'var': col, 1004 'percentile': f'p{p}', 1005 'value': perc, 1006 }, 1007 } 1008 return [sort, group, project] 1009 1010 def inner(ctx): 1011 # for each column and requested percentile, build a pipeline 1012 # all pipelines will be combined into a $facet stage to 1013 # calculate every column/percentile tuple in parallel 1014 facets = {} 1015 unwind = [] 1016 # for each column build a pipeline to calculate the percentiles 1017 for col in ctx.columns: 1018 for p in pctls: 1019 # e.g. outcol for perc .25 of column abc => abcp25 1020 outcol = f'{col}_p{p}'.replace('0.', '') 1021 facets[outcol] = calc(col, p, outcol) 1022 unwind.append( 1023 {'$unwind': '$' + outcol}, 1024 ) 1025 # process per-column pipelines in parallel, resulting in one 1026 # document for each variable + percentile combination 1027 facet = {'$facet': facets} 1028 # expand single document into one document per variable + percentile combo 1029 # the resulting set of documents contains var/percentile/value 1030 expand = [ 1031 { 1032 '$project': { 1033 'value': { 1034 '$objectToArray': '$$CURRENT', 1035 }, 1036 }, 1037 }, 1038 {'$unwind': '$value'}, 1039 { 1040 '$replaceRoot': { 1041 'newRoot': '$value.v', 1042 }, 1043 }, 1044 ] 1045 pipeline = [facet, *unwind, *expand] 1046 return pipeline 1047 1048 return inner