Source code for abacus.mde_researcher.mde_research_builder

from typing import Union
import itertools
import numpy as np
import pandas as pd
from abacus.splitter.params import SplitBuilderParams
from abacus.mde_researcher._experiment_structures import (
    MdeAlphaExperiment,
    MdeBetaExperiment,
)
from abacus.mde_researcher._abstract_mde_experiment_builder import (
    AbstractMdeResearchBuilder,
)
from abacus.mde_researcher.multiple_split_builder import MultipleSplitBuilder
from abacus.mde_researcher.params import MdeParams
from abacus.auto_ab.abtest import ABTest
from abacus.auto_ab.params import ABTestParams


[docs] class MdeResearchBuilder(AbstractMdeResearchBuilder): """Calculates I and II type errors for different group sizes and injects.""" def __init__( self, guests: pd.DataFrame, abtest_params: ABTestParams, experiment_params: MdeParams, stratification_params: SplitBuilderParams, ): """ Args: guests (pandas.DataFrame): Dataframe that collected by PrepilotGuestsCollector. abtest_params (ABTestParams): A/B tests params. Using for experiments calculations. experiment_params (MdeParams): Parameters for prepilot experiments. stratification_params (SplitBuilderParams): Params for groups splits and stratifications. """ super().__init__(guests, abtest_params, experiment_params) self.stratification_params = stratification_params self._number_of_decimals = 10 def _calc_experiment_grid_cell( self, guests_with_splits: pd.DataFrame, grid_element: Union[MdeBetaExperiment, MdeAlphaExperiment], ) -> pd.DataFrame: """Calculates stat test for one element of experiment grid. Args: guests_with_splits (pandas.DataFrame): DataFrame with calculated splits for experiment. grid_element (Union[MdeBetaExperiment, MdeAlphaExperiment]): Experiment params. Returns: pandas.DataFrame: Pandas DataFrame with calculated stat test and experiment parameters. """ row_dict = { "metric": [grid_element.metric_name], "split_rate": [ (grid_element.control_group_size, grid_element.target_group_size) ], } split_column = f"is_control_{grid_element.control_group_size}_{grid_element.target_group_size}_{grid_element.split_number}" metric_col = f"{grid_element.metric_name}" guests_with_splits[split_column] = guests_with_splits[split_column].map( { 1: self.abtest_params.data_params.treatment_name, 0: self.abtest_params.data_params.control_name, } ) if isinstance(grid_element, MdeBetaExperiment): guests_with_splits[metric_col].where( guests_with_splits[split_column] == self.abtest_params.data_params.control_name, guests_with_splits[metric_col] * grid_element.inject, axis=0, inplace=True, ) row_dict["Effect"] = [grid_element.inject] self.abtest_params.data_params.group_col = split_column self.abtest_params.data_params.target = metric_col ab_test = ABTest(guests_with_splits, self.abtest_params) ab_test = self.experiment_params.transformations(ab_test) row_dict["effect_significance"] = self.experiment_params.stat_test(ab_test)[ "result" ] return pd.DataFrame(row_dict) def _fill_passed_experiments(self, aggregated_df) -> pd.DataFrame: """Fill Nan for passed experiments. Args: aggregated_df (pandas.DataFrame): Dataframe with calculated experiments. Returns: pandas.DataFrame: Pandas DataFrame with filled values. """ for metric in self._experiment_params.metrics_names: for split in self.group_sizes: passed_mde = aggregated_df[ (aggregated_df.metric == metric) & (aggregated_df.split_rate == split) ]["Effect"].values last_experiment = min(passed_mde) passed_injects = np.setdiff1d( self._experiment_params.injects, passed_mde ) if len(passed_injects) > 0: failed = list( itertools.product( [metric], [split], passed_injects[passed_injects < last_experiment], [f">={self._experiment_params.max_beta_score}"], ) ) # error higher than max_beta_score succes = list( itertools.product( [metric], [split], passed_injects[passed_injects > last_experiment], [f"<={self._experiment_params.min_beta_score}"], ) ) succes.extend(failed) df_passed = pd.DataFrame.from_records(succes) df_passed.columns = aggregated_df.columns aggregated_df = pd.concat([aggregated_df, df_passed]) return aggregated_df def _beta_score_calculation(self, df_with_calc: pd.DataFrame) -> pd.DataFrame: """Calculates II type error for df with calculated experiments. Args: df_with_calc (pandas.DataFrame): Dataframe with calculated experiments. Returns: pandas.DataFrame: DataFrame with II type error. """ res_agg = df_with_calc.groupby(by=["metric", "split_rate", "Effect"]).agg( sum=("effect_significance", sum), count=("effect_significance", "count") ) res_agg["beta"] = round( (1.0 - res_agg["sum"] / res_agg["count"]), self._number_of_decimals ) return res_agg @staticmethod def _fill_res_with_default( df: pd.DataFrame, column_name: str, min_val: float, max_val: float ) -> pd.DataFrame: """Fill column with defalt values. Args: df (pandas.DataFrame): Pandas Dataframe for replace default values. column_name (str): DataFrame's column name for replace values. min_val (float): Minimum value for replace. max_val (float): Maximum value for replace. Returns: pandas.DataFrame: df with replaced values. """ df[column_name] = np.where( df[column_name] >= max_val, f">={max_val}", np.where(df[column_name] <= min_val, f"<={min_val}", df[column_name]), ) return df def _calc_beta( self, guests_with_splits, fill_with_default: bool = True ) -> pd.DataFrame: """Calculates II type error. Args: guests_with_splits (): Dataframe with precalculated splits. fill_with_default (bool): Fill calculated vaules with defaults. Returns: pandas.DataFrame: Pandas DataFrame with II type error. """ beta_scores = pd.DataFrame() res_agg = pd.DataFrame() for metric_name in self.experiment_params.metrics_names: # index of max inject in self.experiment_params.injects max_found_inject_ind = 0 for group_size in self.group_sizes: found_min_inject_flg = False found_max_inject_flg = False for inject in sorted(self.experiment_params.injects, reverse=True)[ max_found_inject_ind: ]: if found_min_inject_flg and found_max_inject_flg: continue else: for split_number in range( 1, self.experiment_params.iterations_number + 1 ): # experiment experiment_params = MdeBetaExperiment( group_sizes=group_size, split_number=split_number, metric_name=metric_name, inject=inject, ) split_column = f"is_control_{experiment_params.control_group_size}_{experiment_params.target_group_size}_{experiment_params.split_number}" one_split_guests = guests_with_splits.loc[ guests_with_splits[split_column].isin([0, 1]) ] experiment_res = self._calc_experiment_grid_cell( one_split_guests, experiment_params ) beta_scores = pd.concat( [beta_scores, experiment_res], axis=0 ) calculated_experiments = ( (beta_scores["split_rate"] == group_size) & (beta_scores["metric"] == metric_name) & (beta_scores["Effect"] == inject) ) res_inject_agg = self._beta_score_calculation( beta_scores[calculated_experiments] ) res_agg = pd.concat([res_agg, res_inject_agg], axis=0) # check if beta score higher than min_beta if ( res_inject_agg["beta"].values >= round( self.experiment_params.min_beta_score, self._number_of_decimals, ) ) and not found_min_inject_flg: max_found_inject_ind = sorted( self.experiment_params.injects, reverse=True ).index(inject) found_min_inject_flg = True if ( res_inject_agg["beta"].values >= round( self.experiment_params.max_beta_score, self._number_of_decimals, ) ) and not found_max_inject_flg: found_max_inject_flg = True res_agg.drop(columns=["sum", "count"], inplace=True) res_agg = res_agg.reset_index() if fill_with_default: res_agg = self._fill_res_with_default( res_agg, "beta", self.experiment_params.min_beta_score, self.experiment_params.max_beta_score, ) # append passed experiments res_agg = self._fill_passed_experiments(res_agg) res_agg["Effect"] = res_agg["Effect"].apply( lambda mde: f"{round((mde//1.0 * 100 + mde%1.0 * 100) - 100, 5)}%" ) res_pivoted = pd.pivot_table( res_agg, values="beta", index=["metric", "Effect"], columns="split_rate", aggfunc=lambda x: x, ) if fill_with_default: res_pivoted.replace( 0, f"<={self.experiment_params.min_beta_score}", inplace=True ) return res_pivoted @staticmethod def _first_found_mde(df_column: pd.Series) -> int: """Calculate max possible MDE for group sizes. Args: df_column (pandas.Series): DataFrame's column with II type error scores. Returns: int: Max possible MDE for group sizes. """ if not df_column[(df_column != 0) & (df_column != 1)]: return "Effect wasn't detected" else: return df_column[(df_column != 0) & (df_column != 1)].idxmax()[1] def calc_alpha( self, guests: pd.DataFrame, is_splitted: bool = False ) -> pd.DataFrame: """Calculates I type error. Args: guests (pandas.DataFrame): Dataframe with guests. is_splitted (bool): If False guests must contain splits for calculation. Otherwise splits will be compute for guests. Returns: pandas.DataFrame: Pandas DataFrame with I type error. """ if not is_splitted: prepilot_guests_collector = MultipleSplitBuilder( guests, self.experiment_params.metrics_names, self.experiment_params.injects, self.group_sizes, self.stratification_params, self.experiment_params.iterations_number, ) guests = prepilot_guests_collector.collect(guests) alpha_scores = pd.DataFrame() for metric_name in self.experiment_params.metrics_names: for group_size in self.group_sizes: for split_number in range( 1, self.experiment_params.iterations_number + 1 ): experiment_params = MdeAlphaExperiment( group_sizes=group_size, split_number=split_number, metric_name=metric_name, ) split_column = f"is_control_{experiment_params.control_group_size}_{experiment_params.target_group_size}_{experiment_params.split_number}" one_split_guests = guests.loc[guests[split_column].isin([0, 1])] experiment = self._calc_experiment_grid_cell( one_split_guests, experiment_params ) alpha_scores = pd.concat([alpha_scores, experiment], axis=0) res_agg = alpha_scores.groupby(by=["metric", "split_rate"]).agg( sum=("effect_significance", sum), count=("effect_significance", "count") ) res_agg["alpha"] = res_agg["sum"] / res_agg["count"] res_agg.drop(columns=["sum", "count"], inplace=True) res_agg = res_agg.reset_index() res_pivoted = pd.pivot_table( res_agg, values="alpha", index=["metric"], columns="split_rate" ) return res_pivoted def collect(self, fill_with_default: bool = True) -> pd.DataFrame: """Calculates I and II types error using prepilot parameters. Args: fill_with_default (bool): Fill calculated vaules with defaults. Returns: pandas.DataFrame: Pandas DataFrames with aggregated results of experiment. """ prepilot_split_builder = MultipleSplitBuilder( self.guests, self.experiment_params.metrics_names, self.experiment_params.injects, self.group_sizes, self.stratification_params, self.experiment_params.iterations_number, ) prepilot_guests = prepilot_split_builder.collect() beta = self._calc_beta(prepilot_guests, fill_with_default) alpha = self.calc_alpha(prepilot_guests, is_splitted=True) return beta, alpha