"Длинный" и "широкий" форматы данных

Данные являются длинными когда для каждого признака выделяется свой, отдельный столбец. Представим что у нас есть некоторое множество студентов, изучающих три языка программирования, причем студенты разбиты на 5 групп и у каждой группы есть свой рейтинг по каждому языку. Мы можем без труда представить эти данные в "длинной" форме:

# придумываем данные:
group = ['F' + str(i//3) for i in range(3, 18)]
course = ['Pyton', 'C++', 'Java']*5
rating = np.random.randint(10, 50, size=15)

# создаем из этих данных датафрейм:
df_long = pd.DataFrame({'Group': group,
                        'Course': course,
                        'Rating': rating})
df_long
Group Course Rating
0 F1 Pyton 32
1 F1 C++ 15
2 F1 Java 39
3 F2 Pyton 12
4 F2 C++ 15
5 F2 Java 21
6 F3 Pyton 43
7 F3 C++ 14
8 F3 Java 36
9 F4 Pyton 18
10 F4 C++ 37
11 F4 Java 32
12 F5 Pyton 44
13 F5 C++ 10
14 F5 Java 48

В длинной форме все признаки (название группы, язык программирования и рейтинг) сгруппированы по столбцам, а строки представляют собой отдельные "наблюдения" - объекты с указанным набором признаков. Очень часто, именно в таком формате данные "скармливаются" алгоритмам машинного обучения. Очень часто такой формат данных используется для хранения временных рядов. Но есть еще и "широкий" формат данных:

df_wide = df_long.pivot(index='Group',
                        columns='Course',
                        values='Rating')
df_wide
Course C++ Java Pyton
Group
F1 15 39 32
F2 15 21 12
F3 14 36 43
F4 37 32 18
F5 10 48 44

В "широком" формате данные выглядят более компактными, так как строки представляют собой признаки, а отдельные наблюдения хранятся в ячейках. Такие данные легче анализировать, а воспринимаются они еще легче, если их представить в виде тепловой карты:

import seaborn as sns
sns.set()
sns.heatmap(df_wide, annot=True);

Тепловая карта широких данных.

Как вы заметили, метод pivot() устроен очень просто, первые два аргумента определяют значения каких столбцов станут индексами и заголовками столбцов, а последний задает значения столбца которые будут помещены в результирующую, "широкую" таблицу. Давайте придумаем еще один пример, пускай у нас есть вот такой датафрейм:

# Выдумываем данные:
date_idx = pd.date_range(start='2020-01-01', end='2020-12-31')
date_idx
feature_1 = ['Red', 'Green', 'Blue']*122
feature_2 = list('ABCDEF')*61
value_1 = np.cumsum(np.random.randn(366))

# Создаем датафрейм, с "типа реальными" данными:
df = pd.DataFrame({'Date': date_idx,
                   'Feature_1': feature_1,
                   'Feature_2': feature_2,
                   'Value_1': value_1})
df
Date Feature_1 Feature_2 Value_1
0 2020-01-01 Red A 1.280320
1 2020-01-02 Green B 2.309967
2 2020-01-03 Blue C 3.446230
3 2020-01-04 Red D 2.758629
4 2020-01-05 Green E 2.558029
... ... ... ... ...
361 2020-12-27 Green B 14.641240
362 2020-12-28 Blue C 15.521487
363 2020-12-29 Red D 13.846629
364 2020-12-30 Green E 14.492868
365 2020-12-31 Blue F 15.604031

Этот датафрейм очень похож на предыдущий, и кажется что мы можем повернуть эти данные в широкий формат. Но ничего не получится:

df.pivot(index='Feature_1',
         columns='Feature_2',
         values='Value_1')
ValueError: Index contains duplicate entries, cannot reshape

Дело в том, что индекс результирующего датафрейма не должен содержать повторяющихся значений, а из этого следует, что пары значений из столбцов Feature_1 и Feature_2 должны разбиваться на непересекающиеся подмножества. Заметьте, что для датафрейма из предыдущего примера это условие выполняется. Данный пример пригодился бы гораздо больше для демонстрации приемов агрегации данных.

Что же делать если все-таки очень хочется повернуть такие неповорачиваемые данные. Обратите внимание что датафрейм df содержит информацию о дате. Очень часто вам придется работать с временными рядами, которые помимо количественных данных ('Value_1') будут содержать еще и категориальные данные ('Feature_1', 'Feature_2'). Однако (!) дата тоже может быть воспринята как комбинация категориальных признаков: признак-год, признак-месяц, признак-день месяца и т.д.

С помощью аксессора dt мы можем получить доступ к месяцу и дню месяца любой даты, а значит можем поместить эти данные в наш датафрейм:

df['Day'] = df['Date'].dt.day
df['Month'] = df['Date'].dt.month
df
Date Feature_1 Feature_2 Value_1 Day Month
0 2020-01-01 Red A 1.280320 1 1
1 2020-01-02 Green B 2.309967 2 1
2 2020-01-03 Blue C 3.446230 3 1
3 2020-01-04 Red D 2.758629 4 1
4 2020-01-05 Green E 2.558029 5 1
... ... ... ... ... ... ...
361 2020-12-27 Green B 14.641240 27 12
362 2020-12-28 Blue C 15.521487 28 12
363 2020-12-29 Red D 13.846629 29 12
364 2020-12-30 Green E 14.492868 30 12
365 2020-12-31 Blue F 15.604031 31 12

Что дальше? А дальше можно заметить, что значения этих двух новых столбцов образуют пары месяц-день, которые могут быть разбиты на непересекающиеся подмножества, следовательно, они могут быть повернуты:

df.pivot(index='Day',
         columns='Month',
         values='Value_1')
Month 1 2 3 4 5 6 7 8 9 10 11 12
Day
1 1.280320 4.499663 4.995611 7.809455 2.789509 3.701714 12.796463 12.607722 22.540160 17.215560 15.543285 15.692926
2 2.309967 4.849108 4.893813 8.584060 3.064709 4.062598 13.345212 12.315211 21.292353 16.927769 16.711957 16.248795
3 3.446230 5.446209 6.459221 8.852954 2.652694 4.559032 13.491173 13.515112 21.302111 16.477393 16.935500 17.651427
4 2.758629 4.943358 6.482055 8.284182 1.233104 5.836615 14.024985 14.712774 20.290859 17.467914 16.031338 20.475901
5 2.558029 4.219435 5.910340 8.225869 0.783635 6.089833 15.161028 15.805101 19.335010 18.581113 15.757614 21.179886
6 2.441635 4.125523 6.345021 8.180521 0.824530 6.787122 13.925873 15.290465 18.279796 18.037706 15.475966 20.984207
7 3.832410 4.186335 7.486341 7.361676 0.983088 6.723896 14.906861 17.318725 18.241858 17.858568 14.692343 20.455460
8 4.846519 4.983882 6.355167 6.273304 0.066668 7.028689 14.779322 18.048511 17.421804 17.226697 14.535815 20.730132
9 3.457392 4.938479 5.241748 6.236852 0.164098 6.670649 16.839607 16.565633 16.781808 16.711535 14.797774 20.459460
10 3.513267 6.309816 4.714442 6.189914 0.168759 8.384687 16.071680 17.792522 17.501697 16.703412 13.618565 20.451517
11 4.041146 6.652065 4.307849 6.512205 -0.034089 10.080500 14.828499 19.012577 17.069420 15.122851 13.110402 19.134122
12 4.205785 6.306073 3.439218 7.972237 0.071666 10.445729 15.768343 19.812321 18.043636 15.632000 12.666769 19.604051
13 4.116227 9.051883 3.755990 6.819247 1.403657 11.136731 14.703597 19.836070 16.982851 16.769261 13.475709 21.417337
14 2.982602 9.041637 4.660545 6.606446 1.881773 10.995132 15.259424 21.697963 15.986117 15.814248 15.048174 21.507058
15 2.557564 10.092654 5.220842 5.728087 1.921805 10.857222 15.137296 20.833738 15.612040 15.604509 14.690829 22.034822
16 3.267154 8.962249 5.800329 5.700436 0.793541 10.874454 14.805577 19.476960 16.366093 14.460775 14.368752 21.738550
17 3.851380 8.568515 7.907889 4.980183 0.621525 12.641269 14.177881 21.542676 16.950196 13.361580 12.857784 23.826108
18 3.939723 7.480232 7.387798 3.731267 -0.023413 13.230486 13.886881 20.373860 18.098437 13.785337 12.307476 23.044278
19 5.390438 5.461422 8.331284 2.411781 0.108509 11.803238 14.632978 20.468362 18.314649 13.929535 13.237107 22.460037
20 6.297791 3.792500 8.038598 4.017413 0.818674 10.539023 14.437609 19.434992 17.895655 13.435562 13.220168 20.868031
21 6.485818 5.203672 8.969631 2.851895 1.708978 10.294971 15.235624 20.189890 17.217551 13.463076 15.112032 21.062359
22 6.515786 4.089452 9.195306 5.159096 0.924272 9.050102 15.486413 21.120262 16.792765 13.931651 14.382543 21.120787
23 5.672433 2.722785 9.416622 3.974338 2.231564 9.231848 15.907123 20.948861 15.553665 12.698836 16.060535 22.222557
24 5.056586 1.997972 9.052705 4.422946 0.997579 10.271095 14.394013 21.079046 16.174482 11.580446 14.715038 19.935368
25 4.589747 2.045224 7.311904 4.290826 0.365493 10.103482 14.060862 21.999951 17.062645 12.208017 15.254084 17.865460
26 6.012402 1.508136 7.176945 3.072318 -1.324701 10.241017 12.061628 22.488478 16.620308 11.387496 14.820201 15.460649
27 5.182279 2.458931 8.803566 4.560745 1.420925 11.165881 12.567365 20.858919 17.391087 10.704322 16.334217 14.641240
28 3.549366 2.470116 8.259062 5.001389 2.781761 10.341632 12.044444 21.634459 17.461757 12.390838 16.964727 15.521487
29 2.031239 4.547036 10.108951 5.547591 2.956593 11.649154 12.342516 20.704986 16.925448 14.421016 16.960148 13.846629
30 2.710428 NaN 8.629203 5.157218 3.772812 12.784799 11.846687 20.979176 17.060442 14.660711 16.679309 14.492868
31 5.153429 NaN 9.111987 NaN 3.316355 NaN 11.112553 21.422006 NaN 14.576556 NaN 15.604031

Благодаря тепловой карте обратить внимание на самые интересные места новой таблицы гораздо проще:

sns.heatmap(df.pivot(index='Day',
         columns='Month',
         values='Value_1'));

Тепловая карта очень широких данных.

Далее, мы можем повернуть эти данные в широкий формат для каждого категориального значения из столбцов Feature_1 и Feature_2, например, вот так:

# Вытаскиваем все строки в которых 
# 'Feature_1' имеет значение 'Red':
df_reds = df[df['Feature_1'] == 'Red']
df_reds
Date Feature_1 Feature_2 Value_1 Day Month
0 2020-01-01 Red A 0.919593 1 1
3 2020-01-04 Red D -0.074999 4 1
6 2020-01-07 Red A 4.355910 7 1
9 2020-01-10 Red D 5.437354 10 1
12 2020-01-13 Red A 2.504805 13 1
... ... ... ... ... ... ...
351 2020-12-17 Red D -15.226387 17 12
354 2020-12-20 Red A -15.711254 20 12
357 2020-12-23 Red D -16.550948 23 12
360 2020-12-26 Red A -18.844241 26 12
363 2020-12-29 Red D -18.495055 29 12
# смотрим на уникальные значения в 
# 'Feature_2':
pd.unique(df_reds['Feature_2'])
array(['A', 'D'], dtype=object)
import matplotlib.pyplot as plt
# строим график:
fig = plt.figure()

ax_1 = fig.add_subplot(1, 2, 1)
ax_2 = fig.add_subplot(1, 2, 2)

df_red_a = df_reds[df_reds['Feature_2'] == 'A'].pivot('Day', 'Month', 'Value_1')
sns.heatmap(df_red_a, ax=ax_1)

df_red_d = df_reds[df_reds['Feature_2'] == 'D'].pivot('Day', 'Month', 'Value_1')
sns.heatmap(df_red_d, ax=ax_2)

ax_1.set(title = 'Feature_2 = "A"')
ax_2.set(title = 'Feature_2 = "D"')

fig.suptitle('Feature_1 = "Red"')

fig.set_figwidth(14)
fig.set_figheight(6)

Поворот в широкий формат подмножества данных, соответствующих определенному категориальному признаку.

Серые участки на тепловых картах соответствуют NaN-ам, т.е. если при повороте в широкий формат каких-то значений нет, то это не приводит к ошибке.

Кстати, последний аргумент - values в методе pivot() может быть опущен, в этом случае мы можем получить "широкие" данные с иерархической индексацией. Давайте вернемся к нашему датафрейму со студентами:

df_long
Group Course Rating
0 F1 Pyton 37
1 F1 C++ 26
2 F1 Java 44
3 F2 Pyton 20
4 F2 C++ 48
5 F2 Java 49
6 F3 Pyton 13
7 F3 C++ 48
8 F3 Java 16
9 F4 Pyton 18
10 F4 C++ 11
11 F4 Java 37
12 F5 Pyton 32
13 F5 C++ 16
14 F5 Java 49

И добавим к нему несколько столбцов:

df_long['missing_classes '] = np.random.randint(0, 5, 15)
df_long['Progress'] = np.random.randint(70, 100, 15)

df_long.head()
Group Course Rating missing_classes Progress
0 F1 Pyton 37 4 99
1 F1 C++ 26 0 76
2 F1 Java 44 1 89
3 F2 Pyton 20 2 88
4 F2 C++ 48 3 78

А теперь повернем этот датафрейм без аргумента values:

df_long.pivot(index='Group', columns='Course')
Rating missing_classes Progress
Course C++ Java Pyton C++ Java Pyton C++ Java Pyton
Group
F1 26 44 37 0 1 4 76 89 99
F2 48 49 20 3 4 2 78 78 88
F3 48 16 13 4 0 3 76 77 82
F4 11 37 18 0 2 2 92 78 84
F5 16 49 32 2 2 3 80 78 86

В итоге получился датафрейм с иерархической индексацией. Аргумент values так же может принимать списки с перечисленными именами столбцов:

df_long.pivot(index='Group',
              columns='Course',
              values=['Rating', 'Progress'])
Rating progress
Course C++ Java Pyton C++ Java Pyton
Group
F1 26 44 37 76 89 99
F2 48 49 20 78 78 88
F3 48 16 13 76 77 82
F4 11 37 18 92 78 84
F5 16 49 32 80 78 86

Более того, параметры index и columns так же могут принимать списки с именами столбцов, которые будут преобразованы к иерархической индексации строк (или столбцов).

df_long['Gender'] = ['Male']*7 + ['Female']*8
df_long
Group Course Rating missing_classes progress Gender
0 F1 Pyton 37 4 99 Male
1 F1 C++ 26 0 76 Male
2 F1 Java 44 1 89 Male
3 F2 Pyton 20 2 88 Male
4 F2 C++ 48 3 78 Male
5 F2 Java 49 4 78 Male
6 F3 Pyton 13 3 82 Male
7 F3 C++ 48 4 76 Female
8 F3 Java 16 0 77 Female
9 F4 Pyton 18 2 84 Female
10 F4 C++ 11 0 92 Female
11 F4 Java 37 2 78 Female
12 F5 Pyton 32 3 86 Female
13 F5 C++ 16 2 80 Female
14 F5 Java 49 2 78 Female
df_long.pivot(index=['Group', 'Gender'],
              columns='Course',
              values=['Rating', 'progress'])
Rating progress
Course C++ Java Pyton C++ Java Pyton
Group Gender
F1 Male 26.0 44.0 37.0 76.0 89.0 99.0
F2 Male 48.0 49.0 20.0 78.0 78.0 88.0
F3 Female 48.0 16.0 NaN 76.0 77.0 NaN
Male NaN NaN 13.0 NaN NaN 82.0
F4 Female 11.0 37.0 18.0 92.0 78.0 84.0
F5 Female 16.0 49.0 32.0 80.0 78.0 86.0