import logging import re import dateparser import numpy as np import pandas as pd from dateparser_data.settings import default_parsers from utils.aws_connectors import run_query from utils.base_attributes_generation import get_fan_attribute_values from .enrichment import Enrichment component_logger = logging.getLogger().getChild("enrichment.enrich_feature_eng") # Optimize and remove some un-necessary dateparsers. See https://dateparser.readthedocs.io/en/v1.0.0/settings.html date_parsers = [x for x in default_parsers if x not in ["relative-time", "no-spaces-time", "custom-formats"]] class EnrichFeatureEngineering(Enrichment): """ Does feature engineering - a common process for extracting additional qualitative information out of existing data. """ source_attribute_ids = [ 29, # eventPurchaseDate 28, # eventPurchaseMonetary 38, # merchPurchaseMonetary 39, # merchPurchaseDate 27, # eventPurchaseQuantity 36, # merchPurchaseQuantity 83, # 'purchaseQuantity' 84, # 'purchaseMonetary' 85, # 'purchaseDate' ] result_attribute_ids = [ 51, # enrHourOfDay 52, # enrDayOfMonth 53, # enrDayOfWeek 66, # enrTransactionMonth 67, # enrTransactionYear 73, # enrPurchaseDate 74, # enrPurchaseMonetary 75, # enrPurchaseQuantity 82, # enrMonetaryBins 94, # enrMaxMonetary ] data_gathering_rule = "strict" # TODO! create RESULT_ATTRIBUTES = dynamic that would work for every enrichment # Needed for dynamically inserting attributes into collection_attribute table result_attribute_dict = { "enrHourOfDay": 51, "enrDayOfMonth": 52, "enrDayOfWeek": 53, "enrTransactionMonth": 66, "enrTransactionYear": 67, "enrPurchaseDate": 73, "enrPurchaseMonetary": 74, "enrPurchaseQuantity": 75, "enrMonetaryBins": 82, "enrMaxMonetary": 94, } def __init__(self, collection_id: int, schema: str, user_id: str, default_value: str = "unknown", **kwargs): super().__init__(collection_id, schema, user_id, default_value) def _run(self): """ Main enrichment method """ source_df = self.available_data["source"] date_outputs = [ "enrHourOfDay", "enrDayOfMonth", "enrDayOfWeek", "enrTransactionMonth", "enrTransactionYear", "enrPurchaseDate", ] if len(source_df) > 1000 and hasattr(source_df, "parallel_apply"): source_df["enrPurchaseQuantity"] = source_df.parallel_apply(self._get_purchase_value, axis=1, args=["83"]) source_df["enrPurchaseMonetary"] = source_df.parallel_apply(self._get_purchase_value, axis=1, args=["84"]) source_df["parsed_date_object"] = source_df.parallel_apply(self._get_date_object, axis=1, args=["85"]) else: source_df["enrPurchaseQuantity"] = source_df.apply(self._get_purchase_value, axis=1, args=["83"]) source_df["enrPurchaseMonetary"] = source_df.apply(self._get_purchase_value, axis=1, args=["84"]) source_df["parsed_date_object"] = source_df.apply(self._get_date_object, axis=1, args=["85"]) date_lambda = lambda x: pd.Series(self._get_date_objects(x["parsed_date_object"])) if len(source_df) > 1000 and hasattr(source_df, "parallel_apply"): source_df[date_outputs] = source_df.parallel_apply(date_lambda, axis=1) else: source_df[date_outputs] = source_df.apply(date_lambda, axis=1) """Moving row_id field to index as we require this data to be preserved for connecting with source""" source_df.set_index("row_id", inplace=True) # source_df = source_df.reset_index() source_df = source_df[["fan_id", "enrPurchaseQuantity", "enrPurchaseMonetary"] + date_outputs] source_df = self._generate_monetary_bins(source_df) source_df = self._check_hours(source_df) source_df = self._get_max_value_per_fan(source_df, field="enrPurchaseMonetary") """ Generate purchase quantity in case purchase monetary exists and we didn't get quantity from source """ if "enrPurchaseQuantity" not in source_df.columns and "enrPurchaseMonetary" in source_df.columns: source_df["enrPurchaseQuantity"] = 1 self.result_df = source_df @staticmethod def _get_date_object(row, col): """ Get date object out of string. We use dateparser to conveniently make date object out of almost any string representation of date - this is pretty fool-proof and can work with stuff like "20 November 2020" as well as "22/11/2016" """ date_string = row[col] global date_parsers try: # To handle "2020-10-20T21:01:14.975163-05:00[US/Central]" if date_string.find("[") != -1: date_string = date_string.split("[")[0] # Adding "en" language makes parsing of 10k dates ~60% faster date_object = dateparser.parse( str(date_string), languages=["en"], settings={"PREFER_DATES_FROM": "past", "PARSERS": date_parsers} ) except Exception as e: date_object = np.nan return date_object @staticmethod def _get_date_objects(date_object): """ Get date string out of date object, for multiple date formats, and return list of date strings """ part_fmts = ["%H", "%d", "%w", "%B", "%Y", "%Y-%m-%d"] try: results = [] for fmt in part_fmts: try: results.append(date_object.strftime(fmt)) except: results.append(np.nan) return results except Exception as e: return [np.nan for fmt in part_fmts] @staticmethod def _get_purchase_value(row, col): value_string = row[col] regex = r"([\d,.]+)" try: value_object = re.search(regex, value_string).group(1) return float(value_object) except Exception as e: pass return np.nan @staticmethod def _generate_monetary_bins(df): """ Grouping Monetary values in to bins # TODO! This should be in analytics modules, with configurable "bins" parameters or something """ try: df["enrMonetaryBins"] = pd.cut( df["enrPurchaseMonetary"], bins=[0.1, 10, 25, 50, 100, 250, np.inf], labels=["0-10", "10-25", "25-50", "50-100", "100-250", "250+"], ) except: pass return df @staticmethod def _get_max_value_per_fan(df, field): """ Get max field value per fan """ try: df["enrMaxMonetary"] = df.groupby("fan_id")[field].transform("max") except: pass return df @staticmethod def _check_hours(df): """ Removing hours column if all values are the same """ try: unique_hours = list(df[~df["enrHourOfDay"].isna()].enrHourOfDay.unique()) if len(unique_hours) <= 1: result = df.drop(columns=["enrHourOfDay"]) else: result = df return result except KeyError: return df def _populate_collection_attribute_table(self): """ Replacing the original method as there are situations when enrichment produces less system_fields""" dynamic_sql = [] for attribute in list(self.result_df.columns): if attribute not in ["fan_id", "row_id"]: dynamic_sql.append(f"({self.enrichment_collection_id}, {self.result_attribute_dict[attribute]})") if len(dynamic_sql) > 0: sql = f""" INSERT INTO {self.schema}.collection_attribute (collection_id, attribute_id) VALUES {', '.join(dynamic_sql)}""" run_query(sql) """ In feature engineering we need to join with fan_id and row_id because the output is row based """ def _generate_fan_attributes(self): """ Getting a list of attributes that are present in this enrichment""" condition = self.system_fields["a_id"].isin(self.result_attribute_ids) attributes_df = self.system_fields[condition][["a_system_name", "a_id"]] attributes_df.rename(columns={"a_system_name": "name", "a_id": "attribute_id"}, inplace=True) fan_attributes_df = get_fan_attribute_values( source_df=self.result_df, attributes_df=attributes_df, collection_id=self.enrichment_collection_id ) self.fan_attribute = fan_attributes_df