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