August 26, 2021

Как правильно группировать значения дискретных факторов

Зачастую для визуализации и анализа распределения дискретных величин требуется особое разбиение на группы значений. Например, это может понадобится при оценке изменчивости фактора с течением времени (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)