Machine Learning: Как да анализираме последователност от състояния/събития?

Анализът на последователност от състояния/събития е една доста интересна задача, която намира широко приложение в различни области. Например в търговията при анализ на поведението на клиентите или при изследване на определена бизнес ситуация като продажба или друга дейност, която преминава през различни етапи.

С получените резултати от анализа можем да достигнем до конкретни изводи и да предприемем действия, с които да подпомогнем работата на бизнеса.

Тази тема беше разгледана на нашия семинар от 12 март 2021Бизнес и маркетинг с R: Как да анализираме поредица от събития и състояния?

По време на семинара чрез практически пример бяха представени възможностите на пакетите TraMineR и arulesSequences за езика R. В тази статия ще ви покажа в контекста на същия пример как можем да анализираме последователност от състояния/събития само че с езика за програмиране Python.

С какви данни ще работим?

Извадката съдържа 2000 наблюдения с данни от социологическо проучване за семейното състояние. Участниците са на възраст между 15 и 30 години.

Състоянията са кодирани по следния начин:

КодСъстояниеОтделноСемействоДецаРазвод
0Parent (P)нененене
1Left (L)даненене
2Married (M)недада/нене
3Left+Marr (LM)даданене
4Childr (C)ненедане
5Left+Childr (LC)данедане
6Left+Marr+Childr (LMC)дададане
7Divorced (D)да/неда/неда/неда

5 произволни реда от данните:

idhoussexbirthyrnat_1_02plingu02p02r01p02r04cspfajcspmoja15a16a17a18a19a20a21a22a23a24a25a26a27a28a29a30wp00tbgpwp00tbgsyr_cuts
32769531woman1926SwitzerlandgermanProtestant or Reformed Churchabout once a monthqualified manual professionsnan00000000033666661385.641.229731919-28
386nanwoman1945nannannannanqualified non-manual professionsnan00000000111111111280.91.136781939-48
762nanman1934nannannannannannan00000000000000331764.671.566121929-38
150189821man1911Switzerlandnannannanunqualified non-manual and manual workersnan00000000000006661158.911.028511909-18
175259191man1951SwitzerlandfrenchRoman Catholiconce a weekintermediate professionsnan0000000000000013757.7410.6724831949-58

Как изглеждат описателните статистики?

За да добием малко повече представа за това с какви данни разполагаме, ще погледнем описателните статистики (мода, медиана, средна стойност, стандартно отклонение и др.). Те ни позволяват на пръв поглед да видим кои са най-често срещаните стойности в данните.

Първо ще погледнем описателните статистики за числовите променливи в извадката. За тази цел ще използваме метода describe, предоставен от библиотеката за анализ на данни Pandas.

# Извеждане на описателните статистики за числови променливи
df.describe()
idhousbirthyra15a16a17a18a19a20a21a22a23a24a25a26a27a28a29a30wp00tbgpwp00tbgs
count17762000200020002000200020002000200020002000200020002000200020002000200020002000
mean73908.71942.530.0140.0520.080.1540.2780.5020.7761.131.4861.8442.3242.7163.0653.43.663.8881193.871.06
std4273610.460.1180.2220.3050.5540.8081.1351.4171.7321.9512.1032.2432.3172.3382.3492.322.279707.3230.628
min27611909000000000000000000
25%3624119350000000000011112797.2890.708
50%72716194400000000.5111233331007.050.894
75%109864195100000111233666661381.111.226
max148921195711666677777777776793.166.029

Годините на раждане са със стойности от 1909 до 1957. Средната година на раждане е 1943 година. За останалите колони describe не дава особено полезна информация, тъй като колоните с години на участниците в проучването (от а15 до а30) съдържат кодове на състоянията. По-нататък в задачата ще анализираме данните в тях.

За да видим описателните статистики за категорийните променливи, ще използваме отново метода describe, но на параметъра include ще зададем стойността object.

# Извеждане на описателните статистики за категорийни променливи
df.describe(include='object')
sexnat_1_02plingu02p02r01p02r04cspfajcspmoj
count200017751647156615661584552
unique221391088
topwomanSwitzerlandgermanProtestant or Reformed Churchonly for family ceremoniesother self-employedother self-employed
freq109216471125711521551258

От таблицата става ясно например, че в извадката има най-много хора, които са швейцарци и такива с роден език немски. Също така ако погледнем колоната пол, ще видим, че мъжете и жените са приблизително равномерно разпределени. Жените са 1092 на брой, а останалите 908 са мъже.

Изследване на данните

Ще работим предимно с колоните от а15 до а30, тъй като те съдържат данните за състоянията, които ще анализираме. За няколко визуализации ще използваме и някои от останалите колони – пол (sex), година на раждане (birthyr) и роден език
(plingu02).

Първо ще отделим само тези колони в нов DataFrame.

# Избиране на определени колони от данните
df_seq = df.iloc[:,9:25]

Данните изглеждат по следния начин:

a15a16a17a18a19a20a21a22a23a24a25a26a27a28a29a30
2030111111111111111
15390000000002277777
11700000003333666666
13670000066666666666
1980111111666666666

Тъй като така с числа е малко по-трудно да разберем за кое състояние точно става въпрос и е нужно да правим справка, ще кодираме стойностите, за да е по-лесна интерпретацията на данните.

# Кодиране на състоянията
df_seq.replace({0:'P', 1:'L', 2:'M', 3:'LM', 4:'C', 5:'LC', 6:'LMC', 7:'D'}, inplace=True)

След кодиране на състоянията данните изглеждат така:

a15a16a17a18a19a20a21a22a23a24a25a26a27a28a29a30
203PLLLLLLLLLLLLLLL
1539PPPPPPPPPMMDDDDD
1170PPPPPPLMLMLMLMLMCLMCLMCLMCLMCLMC
1367PPPPPLMCLMCLMCLMCLMCLMCLMCLMCLMCLMCLMC
198PLLLLLLLMCLMCLMCLMCLMCLMCLMCLMCLMC

Например нека погледнем ред 1170 от таблицата. При него до 20 годишна възраст участникът е живял при родителите си, след което е напуснал и сключил брак на 21 години и 4 години по-късно е имал дете. Състоянието не се променя след това до 30 годишна възраст.

Вероятности за преминаване от едно състояние в друго

С функцията crosstab() първо ще изчислим за всеки ред броя преходи между отделните състояния, след което ще ги групираме и агрегираме с функцията sum() в една обща кръстосана таблица и накрая ще разделим на общия брой за всеки ред, за да получим условните вероятности.

# Създаване на празен списък
trans_matrix = []

# Изчисляване на броя преходи между отделните състояния
for row in df_seq_values:
    trans_matrix.append(pd.crosstab(pd.Series(row[:-1], name='->'),
                                    pd.Series(row[1:], name='to state')))

# Сумиране на стойностите в списъка
trans_matrix = pd.concat(dict(enumerate(trans_matrix))).sum(level=1)

# Сменяне на местата на индексите и колоните
trans_matrix = trans_matrix[['P', 'L', 'M', 'LM', 'C', 'LC', 'LMC', 'D']].reindex(['P', 'L', 'M', 'LM', 'C', 'LC', 'LMC', 'D'])

# Изчисляване на условните вероятности
trans_matrix = trans_matrix.div(trans_matrix.sum(axis=1), axis=0)

Резултат:

->PLMLMCLCLMCD
P0.8860.0550.0150.03200.0010.0110
L00.8900.08300.0040.0230
M000.9690.01000.0110.01
LM0000.787000.1990.014
C000.12500.8120.06200
LC000000.8820.1180
LMC0000000.9940.006
D00000001

Получената таблица е по-различна от стандартната кръстосана таблица, която е симетрична (A -> B е равно на B -> A) и съответно стойностите по диагонала са равни на единица. Тук това не е така и елементите по диагонала отразяват вероятността даденият човек да се намира в конкретното състояние. Най-стабилно е LMC, т.е. участниците, които са семейни, не живеят при родителите и имат деца – 0.994. Другото стабилно състояние е M (сключили брак) със стойност 0.969. При D (разведените) се е получила единица, защото това е последното състояние и данните са ограничени, тъй като обхващат 30 годишен период.

Средна продължителност за пребиваване в състояние

С помощта на стълбовидни диаграми ще представим графично колко време средно участниците пребивават в дадено състояние. Първо ще видим общо за всички участници, а след това според колоната пол.

# Откриване на броя събития по редове
seq_counts = df_seq.apply(pd.Series.value_counts, axis=1).fillna(0).astype(int)

# Изчисляване на средните стойности за всяко състояние
seq_count_means = pd.DataFrame(seq_counts.mean(), columns=['mean'])

# Създаване на фигура
fig = go.Figure()

# Изграждане на стълбовидна диаграма
fig.add_trace(go.Bar(name=str(seq_count_means.index),
                     x=seq_count_means.index,
                     y=seq_count_means['mean']))

# Настройки на визуализацията
fig.update_layout(title_text='Средна продължителност за пребиваване в състояние',
                  yaxis = dict(title='Mean time (n=2000)',
                               tickmode='linear'))

# Показване на визуализацията
fig.show()

Най-високо средно време участниците прекарват в състояние P (при родителите). На второ и трето място по средно време са състоянията L (живеят самостоятелно) и LMC (живеят самостоятелно, имат брак и деца).

Нека сега да видим какви са разликите в средното време за пребиваване в дадено състояние при мъже и жени.

# Добавяне на нова колона за пол
seq_counts['sex'] = df['sex']

# Откриване на средните стойности
seq_counts_gender = seq_counts.groupby('sex').mean().T

# Премахване на колоната за пол
seq_counts.drop('sex', axis=1, inplace=True)

# Създаване на празен списък
data_means = []

# Добавяне на стълбовидни диаграми към празния списък
for x in seq_counts_gender.columns:
    data_means.append(go.Bar(name=str(x),
                              x=seq_counts_gender.index,
                              y=seq_counts_gender[x]))

# Създаване на фигура с данните
fig = go.Figure(data_means)

# Настройки на визуализацията
fig.update_layout(title_text='Средна продължителност за пребиваване в състояние по пол',
                      yaxis = dict(title='Mean time (n=2000)',
                                   tickmode='linear'))

# Показване на визуализацията
fig.show()

От визуализацията става ясно, че мъжете средно живеят по-дълго време при родителите, докато жените по-продължително време са семейни с деца.

Визуализация на определени последователности

На следващите визуализации ще представим определени последователности за конкретни диапазони от индекси. Първо ще видим началните 10, а след това за всички 2000 наблюдения.

# Създаване на нов DataFrame с първите 10 визуализации
seq_counts10 = seq_counts.head(10)

# Създаване на масив с имената на колоните от а15 до а30
ages = np.array(df_seq.columns)

# Създаване на празен списък
data = []

# Добавяне на стълбовидни диаграми към празния списък
for x in seq_counts10.columns:
    data.append(go.Bar(name=str(x),
                       x=seq_counts10[x],
                       y=seq_counts10.index,
                       orientation='h',
                       text=seq_counts10[x],
                       hovertemplate=
                                    '<br><b>Years in state:</b> %{text} '))

# Създаване на фигура с данните
fig = go.Figure(data)

# Настройки на визуализацаията
fig.update_layout(barmode = 'stack',
                  title_text='Първите 10 последователности от събития',
                  bargap=0,
                  xaxis = dict(
                                tickmode = 'array',
                                tickvals = np.arange(1,17),
                                ticktext = ages,
                            ),
                  yaxis = dict(tickmode='linear'))

# Показване на визуализацията
fig.show()

Нека погледнем например реда с индекс 4. Този участник е живял при родителите си до 19 годишна възраст, след което е напуснал дома и на 27 години е сключил брак и е имал дете.

# Създаване на празен списък
data = []

# Добавяне на стълбовидни диаграми към празния списък
for x in seq_counts.columns:
    data.append(go.Bar(name=str(x),
                       x=seq_counts[x],
                       y=seq_counts.index,
                       orientation='h',
                       text=seq_counts[x],
                       hovertemplate=
                                    '<br><b>Years in state:</b> %{text} '))

# Създаване на фигура с данните
fig = go.Figure(data)

# Настройки на визуализацията
fig.update_layout(barmode = 'stack',
                  title_text='Index plot',
                  height=600,
                  width=800,
                  bargap=0,
                  xaxis = dict(
                                tickmode = 'array',
                                tickvals = np.arange(1,17),
                                ticktext = ages,
                            ),
                 yaxis=dict(title='2000 seq. (n=2000)'))
fig.update_traces(marker_line_width=0)

# Показване на визуализацията
fig.show()

На тази визуализация можем да видим, че синьото надделява над останалите цветове до около 19 годишна възраст, което отново потвърждава извода, който направихме по-рано, че участниците прекарват най-много време, живеейки при родителите си. След 21 години прави впечатление най-вече розовият цвят, който е за състоянието LMC, т.е. след тази възраст повечето участници живеят самостоятелно, имат сключен брак и дете.

Използване на алгоритъма PrefixSpan за откриване на чести последователности

Алгоритъмът PrefixSpan е начин да открием кои последователности от състояния/събития се срещат по-често в нашата извадка. Това става на базата на предварително зададен праг (threshold) за честота на срещане.

Алгоритъмът първо открива тези, които са с дължина от 1 елемент, след което продължава с последователности от повече елементи. Полученият резултат е в следния вид:

<последователност> : <честота на срещане>

За нашия пример, нека кажем, че искаме да видим само онези последователности, които се срещат в 25% от данните. Тъй като имаме 2000 наблюдения, е нужно да видим тези последователности, които се срещат в минимум 500 от тях.

Ще използваме пакета на Python – prefixspan за тази цел.

# Създаване на масив от данните с последователностите
data_ps = np.array(df_seq)

# Използване на функцията PrefixSpan
ps = PrefixSpan(data_ps)

# Откриване на честите последователности
frequents = ps.frequent(500)

# Създаване на нов DataFrame
frequents_df = pd.DataFrame(frequents, columns=['count', 'frequent sequence pattern'])
countfrequent sequence pattern
01972['P']
11896['P', 'P']
21847['P', 'P', 'P']
31762['P', 'P', 'P', 'P']
41631['P', 'P', 'P', 'P', 'P']
51411['P', 'P', 'P', 'P', 'P', 'P']
61191['P', 'P', 'P', 'P', 'P', 'P', 'P']
71000['P', 'P', 'P', 'P', 'P', 'P', 'P', 'P']
8833['P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P']
9682['P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P']
10509['P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P', 'P']
11564['P', 'P', 'P', 'P', 'P', 'P', 'P', 'LM']
12677['P', 'P', 'P', 'P', 'P', 'P', 'LM']
13604['P', 'P', 'P', 'P', 'P', 'P', 'LMC']
14543['P', 'P', 'P', 'P', 'P', 'P', 'LMC', 'LMC']
15792['P', 'P', 'P', 'P', 'P', 'LM']
16538['P', 'P', 'P', 'P', 'P', 'LM', 'LM']
17712['P', 'P', 'P', 'P', 'P', 'LMC']
18638['P', 'P', 'P', 'P', 'P', 'LMC', 'LMC']
19556['P', 'P', 'P', 'P', 'P', 'LMC', 'LMC', 'LMC']

От таблицата става ясно, че най-често срещаните последователности са тези, при които участникът живее известно време при родителите, след което сключва брак и напуска дома. В повече от 700 от всички случаи има и деца.

Как са разпределени състоянията във времето и какво се променя?

Със следващите графики ще представим разпределението на състоянията във времето. Първо ще видим общо за всички наблюдения, а след това според пол и роден език.

# Създаване на празен списък 
data = []

# Добавяне на стълбовидни диаграми към списъка
for x in frequencies.columns:
    data.append(go.Bar(name=str(x),
                       x=frequencies.index,
                       y=frequencies[x]))

# Създаване на фигура с данните
fig = go.Figure(data)

# Настройки на визуализацията
fig.update_layout(barmode = 'stack',
                  title_text='Обща Хронограма',
                  bargap=0)

# Показване на визуализацията
fig.show()

Можем да кажем, че 50% от участниците на възраст 22 години живеят при родителите си, след това с нарастване на възрастта това започва да се променя. Има много малко хора, които са разведени и те са след 25 годишна възраст. Преди това са много редки такива случаи.

# Създаване на списъци със стойностите за пол и роден език
genders = ['man', 'woman']
languages = ['french', 'german', 'italian']

# Филтриране на данните по определен пол и роден език и запазване в нови променливи
for gender in genders:
    locals()['df_seq_'+str(gender)] = df[df['sex']==gender].iloc[:,9:25]
for lang in languages:
    locals()['df_seq_'+str(lang)] = df[df['plingu02']==lang].iloc[:,9:25]

# Създаване на списъци с променливите и отделните наименования
dframes = [df_seq_man, df_seq_woman, df_seq_french, df_seq_german, df_seq_italian]
names = ['man','woman','french','german','italian']

# Откриване на честотите за филтрираните данни по определен пол и роден език
for name, frame in zip(names,dframes):
    frame.replace({0:'P', 1:'L', 2:'M', 3:'LM', 4:'C', 5:'LC', 6:'LMC', 7:'D'}, inplace=True)
    locals()['frequencies_'+str(name)] = frame.apply(pd.Series.value_counts, axis=0).fillna(0) / frame.shape[0]

# Създаване на списък с променливите, съдържащи стойностите за честотите
frequencies_list = [frequencies_man, frequencies_woman, frequencies_french, frequencies_german, frequencies_italian]

# Разместване на индексите и транспониране на данните
for name, frame in zip(names, frequencies_list):
    locals()['frequencies_'+str(name)+'_t'] = frame.reindex(['P','L','M','LM','C','LC','LMC','D']).T

# Създаване на списък с новите данни
transposed_freqs = [frequencies_man_t, frequencies_woman_t, frequencies_french_t, frequencies_german_t, frequencies_italian_t]

# Създаване на празен списък с определен брой елементи и списък с наименованията на визуализациите
data = [ [] for _ in range(len(transposed_freqs)) ]
labels = ['мъже', 'жени', 'хора с роден език френски', 'хора с роден език немски', 'хора с роден език италиански']

# Изграждане на визуализациите
for frame, index, label in zip(transposed_freqs, range(5), labels):

    # Добавяне на стълбовидни диаграми към празния списък
    for x in frame.columns:
        data[index].append(go.Bar(name=str(x),
                                  x=frame.index,
                                  y=frame[x]))
    # Създаване на фигурата с данните
    fig = go.Figure(data[index])

    # Настройки на визуализацията
    fig.update_layout(barmode = 'stack',
                          title_text='Хронограма ' + label,
                          bargap=0)

    # Показване на визуализацията
    fig.show()

Хронограмите според пола на участниците изглеждат по следния начин:


Има доста повече разведени при жените отколкото при мъжете. На 23 годишна възраст 51% от мъжете живеят при родителите си, за разлика от жените, от които 34% са в това състояние на тази възраст. Можем да кажем, че жените по-рано напускат родния дом и живеят самостоятелно. Също отново можем да достигнем до извода, че повече жени са със сключен брак и имат деца отколкото мъже.

Хронограмите според родния език на участниците са следните:



18% от всички италианци, на 29 годишна възраст все още живеят при родителите си за разлика от французите и германците, които са респективно 7% и 9%. За сметка на това обаче само 1% от италианците са разведени. Най-голям е броят на разведените при французите.

Ентропия

Като метрика за оценка на състоянията ще използваме ентропия. Тя е мярка за неопределеност и се повишава с нарастване на състоянията. Ентропията достига максимум, когато всички състояния са с равна вероятност и може да е вертикална или хоризонтална

При изчисление на ентропията ще използваме основа 8, защото имаме 8 възможни състояния. Необходимо е първо да открием стойностите на метриката по колони (вертикална), след което ще визуализираме данните в линейна диаграма.

# Изчисление на ентропията по колони
entropy_df = pd.DataFrame({'ages': frequencies.T.columns,
                           'entropy': np.round(entropy(frequencies.T, base=8), 3)})

# Създаване на фигура
fig = go.Figure()

# Изграждане на визуализация
fig.add_trace(go.Scatter(x = entropy_df['ages'],
                         y= entropy_df['entropy']))

# Настройки на визуализацията
fig['layout']['title'] = 'Entropy index line chart'
fig['layout']['xaxis']['title'] = 'Index'
fig['layout']['yaxis']['title'] = 'Entropy index (n=2000)'

# Показване на визуализацията
fig.show()

От графиката можем да достигнем до следните изводи:

  • на 15 години всички се намират в едно и също състояние (при родителите) ~ 0
  • между 24-27 години – активна смяна на семейния статус
  • над 27 има лек спад, но нивото остава високо

Нека сега да видим какво е положението според пола на участниците и годините на раждане. Ще изчислим ентропията по редове (хоризонтална), след което ще запазим данните в нов DataFrame и след това ще изградим диаграми тип кутия.

# Изчисляване на ентропията по редове
entropy_rows = pd.DataFrame({'entropy': entropy(seq_counts, base=8, axis=1)})

# Разделяне на данните по години
df['yr_cuts'] = pd.cut(df['birthyr'],
                       bins=[1909, 1918, 1928, 1938, 1948, 1958],
                       labels=['1909-18', '1919-28', '1929-38', '1939-48', '1949-58']) 

# Създаване на нов DataFrame
df_ge_yr = pd.DataFrame({'sex': df['sex'], 'yrs': df['yr_cuts'], 'entropy': entropy_rows['entropy']})

# Сортиране на стойностите по колоната с годините
df_ge_yr.sort_values(by='yrs', inplace=True)

В следната таблица са представени 5 случайни реда от данните, които ще използваме за изграждане на визуализациите по-надолу:

sexyrsentropy
940man1929-380.37388
61woman1939-480.329566
1298man1949-580.423927
1103woman1949-580.473246
1392woman1949-580.5
# Създаване на фигура
fig = go.Figure()

# Изграждане на визуализация
fig.add_trace(go.Box(x=df_ge_yr['sex'],
                     y=df_ge_yr['entropy'],
                     boxmean=True))

# Настройки на визуализацията
fig['layout']['title'] = 'Entropy boxplots by gender'
fig['layout']['yaxis']['title'] = 'Entropy index'

# Показване на визуализацията
fig.show()

На визуализацията можете да видите диаграми тип кутия, представящи разпределението на ентропията според пола на участниците. Можем да кажем, че жените са по-склонни да сменят социалния си статус отколкото мъжете.

# Създаване на фигура
fig = go.Figure()

# Изграждане на визуализацията
fig.add_trace(go.Box(x=df_ge_yr['sex'],
                     y=df_ge_yr['entropy'],
                     boxmean=True))

# Настройки на визуализацията
fig['layout']['title'] = 'Entropy boxplots by gender'
fig['layout']['yaxis']['title'] = 'Entropy index'

# Показване на визуализацията
fig.show()

Необходимо е към годините на раждане да добавим съответните години от колоните a15-a30, за да добием представа за кой период става въпрос. От визуализацията можем да стигнем до извод, че през 70-те и 80-те години на XX век, хората са били по-активни в смяната на своя социален статус, отколкото в първата половина на века.

Цялостния пример можете да изтеглите от тук.

Извод

С получените резултати от анализа на състояния и събития можем да намерим отговорите на конкретни бизнес въпроси като например кои са типичните последователности, има ли прилики между тях и можем ли да отделим определени модели. Това би помогнало много за подобряването на работата на бизнеса.

Искате да научите повече за машинното обучение?

Включете се в курса по машинно обучение и анализ на данни с Python.

Научете повече

Автор: Десислава Христова