import logging import random import re import string import numpy as np import pandas as pd import requests from utils.aws_connectors import df_to_db, run_query, pd_read_sql from utils.base_attributes_generation import get_fan_attribute_values from utils.data_model_utils import string_to_db from .enrichment import Enrichment component_logger = logging.getLogger().getChild("tasks.enrich_unique_gender") class EnrichUniqueGender(Enrichment): source_attribute_ids = [ # userGender 7, # enrUserFirstName 49, ] result_attribute_ids = [98] # enrUniqueGender data_gathering_rule = "strict" 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) self.schema_existing_fan_data = pd.DataFrame({}) self.global_name_gender = pd.DataFrame({}) self.api_export_table_name = "first_name_gender" self.dummy_gender_enrichment = False # change this to true if you want to turn off API calls def _run(self): """ Main enrichment method """ source_df = self.available_data["source"] # Join existing current schema fan<->gender data to our source collection fans self._get_existing_schema_fan_data() source_df = source_df.merge(self.schema_existing_fan_data, how="left", on="fan_id") # Join existing global name<->gender data to our source collection fans self._get_existing_global_data() source_df = source_df.merge(self.global_name_gender, how="left", left_on="49", right_on="name") """ Populate gender from current schema fan<->gender data when it's not missing and unknown. This gets results from previously run collections. """ source_df["enrExisting1"] = source_df[ ~source_df["schema_gender"].isna() & source_df["schema_gender"] != "unknown" ]["schema_gender"] """ Populate gender from global name<->gender data when enrUniqueGender missing. This gets results from previous genderapi runs. """ source_df["enrExisting2"] = source_df[~source_df["global_gender"].isna() & source_df["enrExisting1"].isna()][ "global_gender" ] source_df["enrExistingGender"] = ( source_df[["enrExisting1", "enrExisting2"]].fillna(method="bfill", axis=1).iloc[:, 0] ) """ Populate gender from source data userGender (and normalize it) when enrExistingGender missing """ source_df["enrGivenGender"] = source_df[~source_df["7"].isna() & source_df["enrExistingGender"].isna()][ ["7"] ].applymap(self._normalize_gender) source_df["enrExistingGender"] = ( source_df[["enrExistingGender", "enrGivenGender"]].fillna(method="bfill", axis=1).iloc[:, 0] ) # Select fans for whom we didn't get gender from above attempts not_enriched_df = source_df[source_df["enrExistingGender"].isna()][["fan_id", "49"]] # Get unique names so we'd make less API calls unique_new_names = not_enriched_df["49"].unique() # Running gender.api call to enrich name enriched_df = self._enrich_names(unique_new_names) enriched_df.rename(columns={"gender": "enrUniqueGenderAPI"}, inplace=True) source_df = source_df.merge( enriched_df[["name", "enrUniqueGenderAPI"]], how="left", left_on="49", right_on="name" ) """ Getting the first non null value (if exists) """ source_df["enrUniqueGender"] = ( source_df[["enrExistingGender", "enrUniqueGenderAPI"]].fillna(method="bfill", axis=1).iloc[:, 0] ) # Filling in empty values with default null value source_df["enrUniqueGender"].fillna(value=self.default_value, inplace=True) # We don't need to preserve any remnants from prior calculations source_df = source_df[["fan_id", "row_id", "enrUniqueGender"]] """ See if we can insert anything new to our centralized helper fan_table. - Left join existing fans from current schema to our final results. - Select rows where previously gender was missing altogether, and now it is male/female. - Append new rows to fan_table. It should leaves columns untouched and only insert fan_id and gender. """ new_gender_rows = source_df.merge(self.schema_existing_fan_data, how="left", on="fan_id") new_gender_rows = new_gender_rows[new_gender_rows["schema_gender"].isna()][["fan_id", "enrUniqueGender"]] new_gender_rows.rename(columns={"enrUniqueGender": "gender"}, inplace=True) # deprecated, was: df_to_db(df=new_gender_rows, schema=self.schema, table_name="fan_table", if_exists="append") string_to_db(df=new_gender_rows, schema=self.schema, table="fan_table", specify_columns=True) """ We also want to see if we can update anything in our previously existing table. - Inner join existing fans from current schema to our final results (because we update existing). - Select rows where enrichment has known gender but existing gender was not male/female. - Generate random table name (in case we have concurrent gender enrichments, we want to avoid conflicts) - Create a temporary table to help us update fan_table, and run the update query """ update_gender_rows = self.schema_existing_fan_data.merge(source_df, how="left", on="fan_id") update_gender_rows = update_gender_rows[ (~update_gender_rows["schema_gender"].isin(["male", "female"])) & (update_gender_rows["enrUniqueGender"].isin(["male", "female"])) ][["fan_id", "enrUniqueGender"]] update_gender_rows.rename(columns={"enrUniqueGender": "gender"}, inplace=True) temp_table_name = "temp_gender_" + "".join(random.choices(string.ascii_letters + string.digits, k=10)).lower() # deprecated, was: df_to_db(df=update_gender_rows, schema=self.schema, table_name=temp_table_name, if_exists="replace") string_to_db(df=update_gender_rows, schema=self.schema, table=temp_table_name, create_table=True) sql = f""" UPDATE {self.schema}.fan_table AS f SET gender = t.gender FROM {self.schema}.{temp_table_name} AS t WHERE f.fan_id = t.fan_id; DROP TABLE {self.schema}.{temp_table_name}; """ run_query(sql) source_df = source_df.drop_duplicates() """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) self.result_df = source_df[["fan_id", "enrUniqueGender"]] @staticmethod def _chunks(lst, n): """Yield successive n-sized chunks from lst.""" for i in range(0, len(lst), n): yield lst[i : i + n] def _get_existing_schema_fan_data(self): """ Getting existing fans and their genders from this user's schema. """ sql = f"SELECT DISTINCT fan_id, gender as schema_gender FROM {self.schema}.fan_table" self.schema_existing_fan_data = pd_read_sql(sql) def _get_existing_global_data(self): """ Getting a list of existing unique first_name - gender pairs. Rank by accuracy and samples to get unique. NB! Notice that with some earlier runs, we can get "unknown", but with later runs, we can get gender. See "stewart" """ sql = f""" select distinct name, gender as global_gender from ( select name, gender, rank() over(partition by name_sanitized order by accuracy desc, samples desc) as rank from commons.first_name_gender -- where gender != 'unknown' ) a where a.rank = 1 ; """ self.global_name_gender = pd_read_sql(sql) def _enrich_names(self, names_list: list): """ Expected result df structure: name, name_sanitized, country, gender, samples, accuracy """ response_list = [] empty_result = pd.DataFrame( {"name": [], "name_sanitized": [], "country": [], "gender": [], "samples": [], "accuracy": []} ) if len(names_list) > 0: chunksize = 100 for chunk in self._chunks(names_list, chunksize): first_names = ";".join([str(x) for x in chunk if x != "nan"]) if self.dummy_gender_enrichment: """ Creating dummy data for testing""" dummy_data = ["dummy_enriched_value" for x in chunk] df = pd.DataFrame( { "name": chunk, "name_sanitized": dummy_data, "country": dummy_data, "gender": dummy_data, "samples": dummy_data, "accuracy": dummy_data, } ) else: """" Running actual enrichment against API""" try: gender_response = self.get_gender_from_first_names(first_names) if gender_response.get("errno"): component_logger.error(gender_response.get("errmsg")) df = None else: df = pd.DataFrame(gender_response["result"]) df_to_db(df, schema="commons", table_name=self.api_export_table_name, if_exists="append") except Exception as e: component_logger.error(e) df = None response_list.append(df) if not all(response is None for response in response_list): result_df = pd.concat(response_list, ignore_index=True) else: result_df = empty_result else: result_df = empty_result return result_df @staticmethod def get_gender_from_first_names(first_names): """ Makes API call to gender-api.com. 5000 calls a month cost 7€ (25k is 31€) This is the response object: {'name': 'nan', 'name_sanitized': 'Nan', 'country': '', 'gender': 'female', 'samples': 3704, 'accuracy': 71, 'duration': '36ms', 'credits_used': 1} We only care about "gender" """ component_logger.info(f"Running gender.api call with {len(first_names.split(';'))} names: {first_names}") api_token = "PhZsKTqgYdbdQTgKgd" # "EMkBMEHmfeJCTGtCXD" api_url = f"https://gender-api.com/get?name={first_names}&multi=true&key={api_token}" # api_url = f"https://gender-api.com/get?name={first_name}&multi=true&key={api_token}" headers = { "Content-Type": "application/json", "User-Agent": "FanSifter Python Client", "Accept": "application/json", } api_response = requests.get(api_url, headers=headers) if api_response.status_code == 200: return api_response.json() else: component_logger.info("[!] HTTP {0} calling [{1}]".format(api_response.status_code, api_url)) return np.nan @staticmethod def _normalize_gender(gender: str): if gender == np.nan or str(gender) == "nan" or gender is None: return np.nan else: gender = str(gender).lower() if re.match("f$|female|woman$", gender) is not None: return "female" elif re.match("m$|male|man$", gender) is not None: return "male" else: return np.nan """ Even though we enrich unique fan, output is row based (like other non-algo enrichments) """ 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