Как правильно группировать значения дискретных факторов
Зачастую для визуализации и анализа распределения дискретных величин требуется особое разбиение на группы значений. Например, это может понадобится при оценке изменчивости фактора с течением времени (PSI тесты), измерения расстояния между распределениями Кульбака-Лейблера.
Почему просто не взять распределение по всем значениям? Хот бы потому что отсутствие некого значения в новом распределении делает вероятность равной нулю и невозможным получить конечное число в формулах с таким знаменателем или вычислением логарифма.
Фактически нашей целью будет вычисление групп категорий с "немаленьким" представительством значений нашей случайной величины (зададим его сами). Для этого сгруппируем все значения по убыванию их относительной встречаемости в выборке и для каждого будем проверять соответствие заданному порогу, если меньше объединим с последующим и так до конца. Если на последнем шаге останется группа с относительной долей встречаемости меньше порога, то добавим ее в предыдущую.
Зададим произвольный набор значений:
import numpy as np import pandas as pd np.random.seed(0) s = pd.Series(np.random.randint(10, size=20)) s
Установим минимальный порог и выведем частоту встречаемости значений с использованием метода value_counts с параметром normalize=True:
bin_min_level = 0.11 stat_df = s.value_counts(normalize=True).reset_index().rename( columns={'index':'cats', 0:'pers'}).sort_values(by='pers', ascending=False) stat_df
Объединение в группы можно реализовать, например, так:
bins = [] bins_gr = [] cumsum_t = 0 for row in stat_df.itertuples(): cumsum_t += row.pers if cumsum_t>=bin_min_level: bins_gr.append(row.cats) bins.append(bins_gr) bins_gr = [] cumsum_t = 0 else: bins_gr.append(row.cats) if len(bins_gr)>0: bins[-1].extend(bins_gr) bins
Но с учетом того, что мы работаем с объектами из библиотеки Pandas удобнее могло быть использование немного другого способа, заключающегося не в создании списка списков, а разметки групп значений в новой столбце:
cat_new_ind = 0 cumsum_t = 0 cats_new = pd.Series(np.zeros(shape=stat_df.shape[0])) for row in stat_df.itertuples(): cumsum_t += row.pers if cumsum_t>=bin_min_level: cats_new.iloc[row.Index]= cat_new_ind cat_new_ind+=1 cumsum_t = 0 else: cats_new.iloc[row.Index]= cat_new_ind
cats_map = pd.DataFrame({'first':stat_df['cats'],'second':cats_new}) cats_map
Теперь, если нам потребуется сравнить распределение нашей случайной величины с другой, принимающей значения из того же множества, можно отобразить ее на новые группы и, возможно, опять скорректировать их в соответствии с указанным алгоритмом:
s_new = pd.Series(np.random.randint(10, size=20), name='test') s_new
stat_new_df = pd.merge(s_new, cats_map, left_on='test', right_on = 'first') stat_new_df
Распределение не удовлетворяет нашему критерию в не менее 11% значений в каждой группе:
stat_new_df['second'].value_counts(normalize=True
Повторяем манипуляции и получаем новые группы:
bin_min_level = 0.11 s = stat_new_df['second'] stat_df = s.value_counts(normalize=True).reset_index().rename( columns={'index':'cats', 'second':'pers'}).sort_values(by='pers', ascending=False) cat_new_ind = 0 cumsum_t = 0 cats_new = pd.Series(np.zeros(shape=stat_df.shape[0])) for row in stat_df.itertuples(): cumsum_t += row.pers if cumsum_t>=bin_min_level: cats_new.iloc[row.Index]= cat_new_ind cat_new_ind+=1 cumsum_t = 0 else: cats_new.iloc[row.Index]= cat_new_ind cats_map_new = pd.DataFrame({'first':stat_df['cats'],'second':cats_new}) cats_map_new
Теперь объединим новое разбиение со старым:
pd.merge(cats_map, cats_map_new, left_on='second', right_on = 'first', how='left').rename(columns={ 'first_x':'first','second_x':'second','second_y':'third' }).drop('first_y', axis=1)
На эту таблицу теперь можно опираться, чтобы трансформировать распределения наших случайных величин. Для этого потребуется объединить каждую из них с датафреймом по столбцу "first" и выбрать "third". Операцию merge можно производить только с именованными сериями (а мы имя первой не дали, более того потом ее затерли) продемонстрируем результаты для второй:
trans_df = pd.merge(cats_map, cats_map_new, left_on='second', right_on = 'first', how='left').rename(columns={ 'first_x':'first','second_x':'second','second_y':'third' }).drop('first_y', axis=1) pd.merge(s_new, trans_df, left_on = 'test', right_on='first')['third'].value_counts(normalize=True)
Теперь приведем в порядок код, а повторы объединим в функцию. Также зададим имя для первой серии:
import numpy as np import pandas as pd np.random.seed(0) s = pd.Series(np.random.randint(10, size=20), name='s1')
def reorg_cats(s, bin_min_level=0.1): name = s.name if s.name else 0 stat_df = s.value_counts(normalize=True).reset_index().rename( columns={'index':'cats', name:'pers'}).sort_values(by='pers', ascending=False) cat_new_ind = 0 cumsum_t = 0 cats_new = pd.Series(np.zeros(shape=stat_df.shape[0])) for row in stat_df.itertuples(): cumsum_t += row.pers if cumsum_t>=bin_min_level: cats_new.iloc[row.Index]= cat_new_ind cat_new_ind+=1 cumsum_t = 0 else: cats_new.iloc[row.Index]= cat_new_ind return pd.DataFrame({'first':stat_df['cats'],'second':cats_new})
cat_map1 = reorg_cats(s, bin_min_level=0.11) s_new = pd.Series(np.random.randint(10, size=20), name='s2') s_new_tr = pd.merge(s_new, cat_map1, left_on='s2', right_on = 'first')['second'] cat_map2 = reorg_cats(s_new_tr, bin_min_level=0.11) trans_df = pd.merge(cat_map1, cat_map2, left_on='second', right_on = 'first', how='left').rename(columns={ 'first_x':'first','second_x':'second','second_y':'third' }).drop('first_y', axis=1)
p = pd.merge(s_new, trans_df, left_on = 's2', right_on='first')['third'].value_counts(normalize=True) q = pd.merge(s, trans_df, left_on='s1', right_on='first')['third'].value_counts(normalize=True)
Теперь p и q можно использовать для подсчета расстояния Кульбака-Лейблера:
from scipy.stats import entropy entropy(p,q)