В тази публикация ще подчертая някои от нещата, които трябва да имате предвид при пускането на байесови модели в производство и как те се сравняват с „класическите“ ML модели. Пълният код може да бъде намерен в GitHub.

Въведение

Байесовото моделиране придобива голяма популярност в общността на науката за данни. Това е особено вярно в области като маркетинга, където моделите за напр. оценката на целия живот на клиента (CLV) или моделирането на маркетинговия микс (MMM) стават все по-„байесови“.

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

  • Как можем да получим прогнози за невиждани данни от байесов модел?
  • Как и кога преквалифицираме модела?
  • Как можем да вкараме предишни в нашия модел?
  • Как да се справим с новите категориални стойности?

Всичко това ще бъде обяснено чрез пример и с помощта на библиотеката на Python PyMC, която ще използваме за извършване на действителното моделиране.

Bayes в производството

В примера имаме магазин, който иска да предвиди сумата, която клиентите харчат при всяко посещение. Тъй като това не е adhoc анализ, те трябва да прогнозират тази цифра непрекъснато.

Процес на генериране на данни

Функцията по-долу симулира N посещения в нашия магазин, където всяко посещение води до транзакция. Първо, клиент за тази транзакция се избира на случаен принцип. След това можем да получим сумата на тази транзакция като комбинация от функцията x и дохода на клиента с някои коефициенти (които, разбира се, не са ни известни).

def generate_data(N, n_cust, prob_new=0, customer_ids=None, income=None):
    """
     generates either new data or continues to generate data.
    """

    if customer_ids is None:
        customer_ids = np.arange(0,n_cust)
    if income is None:
        income = np.random.normal(0,3, len(customer_ids))

    transactions = []

    for t in range(N):
        transaction = {}
        cust = np.random.choice(customer_ids)
        x = np.random.normal()
        spend = 0.5 * income[cust] + x * 0.3 + np.random.normal(0,1)
        transaction['cust_id'] = cust
        transaction['spend'] = spend
        transaction['x'] = x
        transactions.append(transaction)

        # generate a new customer with some probability
        if np.random.random() < prob_new:
            income = np.append(income, np.random.normal(3))
            customer_ids = np.append(customer_ids, max(customer_ids)+1)

    return pd.DataFrame(transactions), income, customer_ids

Имайте предвид, че функцията x не е свързана с клиента. Освен това никога не виждаме директно доходите на клиента. Проявява се само чрез харченето. При всяка транзакция също има малък шанс да се генерира нов клиент. В такъв случай ние също генерираме известен доход за тях. След това този клиент може да се появи като клиент в следващите транзакции. Обърнете внимание, че за простота предположих, че и двата изразходват доход като евентуално отрицателен, тъй като фокусът на тази статия не е самото моделиране, а по-скоро как можем да използваме модела в производството.

По-долу има и графика, която показва средните разходи на всеки клиент.

Моделът

Ние дефинираме сравнително прост модел на PyMC. Всеки клиент получава своя уникален прихващане, който допринася за очакваните разходи на транзакцията. Накрая правим извод, като създаваме две маркови вериги, от които теглим 1000 пъти всяка.

with pm.Model() as model:

    obs = pm.MutableData('obs', df.spend.values)
    x = pm.MutableData('x', df.x.values)
    cust_id = pm.MutableData("cust_id", df.cust_id.values)

    sig = pm.HalfNormal("sig", sigma=10)
    beta = pm.Normal("beta", mu=0, sigma=10)
    alpha = pm.Normal("alpha", mu=0, sigma=10)
    cust_exp = pm.Normal("cust_exp", mu=0, sigma=5, shape=int(max(df.cust_id.values)+1))
    y = pm.Normal("y", mu=cust_exp[cust_id] + beta*x, sigma=sig, observed=obs)

    trace = pm.sample(1000)

Също така е важно да увием нашите наблюдавани данни в обекта pm.MutableData. Това е така, че можем да променим стойностите по-късно и да получим прогнози за нови точки от данни.

Получаване на прогнози от байесов модел

В нормалните ML модели можем просто да получим прогноза, като прекараме данните през някакво уравнение (напр. регресия) или надолу по дърво (напр. дърво на решения) (силно опростено). Прогнозите от байесов модел се получават чрез теглене на проби от задното разпределение на параметрите на модела, дадени на наблюдаваните данни. Този процес често се извършва с помощта на методите на markov mhain monte carlo (MCMC) или други байесови техники за вземане на проби. След това можем да използваме тези проби, за да генерираме прогнози за нови или ненаблюдавани точки от данни. Този подход предоставя не само точкови прогнози, но и количествено определя несигурността, свързана с прогнозите, което го прави мощен инструмент за вземане на решения и анализ.

В PyMC можем да направим това чрез кода по-долу. Обектът за проследяване съдържа параметрите на модела, които получихме от „напасването“ на модела и сега ще добавим нашите прогнози към него.

with model:
    pm.sample_posterior_predictive(trace, extend_inferencedata=True)

Обърнете внимание, че пробите от задната прогноза са триизмерен масив numpy. В нашия случай масивът има формата (2,1000,100). Това е така, защото проведохме две паралелни вериги с 1000 проби всяка и имаме 100 наблюдения.

trace.posterior_predictive["y"].values.shape

>>> (2,1000,100)

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

trace.posterior_predictive["y"].values.mean(axis=0).mean(axis=0)

По този начин получаваме един масив със 100 стойности, които са точковите прогнози. Но изводът на Bayes превъзхожда количествено определяне на несигурността. Също така е възможно да се получат оценки на несигурността за едно наблюдение, както е показано по-долу, където начертаваме разпределението на прогнозираните стойности за първото наблюдение.

fig, ax = plt.subplots()
ax.hist(trace.posterior_predictive["y"].values.mean(axis=0)[:,0], bins=50)
ax.axvline(df.spend.values[0], color="red")
ax.set(title="Posterior predictive on first data point", xlabel="spend", ylabel="Frequency")

По-горе получихме прогнози за наблюдения, които вече видяхме по време на извода. Когато искаме да получим прогнози за невидяни (извън извадката) данни, трябва първо да заменим невидимите стойности с нашите стари, както е показано по-долу. Тъй като обвихме нашите променливи с pm.MutableData в нашата спецификация на модела, сега можем да „настроим данните“ на нови стойности и след това да получим прогнози за тях.

with model:
    pm.set_data(
        {"x": df_new.x.values, 
        "cust_id": df_new.cust_id.values, 
        "obs": df_new.spend.values
        })
    post_pred_new = pm.sample_posterior_predictive(trace)

Важното е, че използваме проследяването, което получихме от „старите“ данни, тъй като то съдържа параметрите, които сега можем да използваме, за да предвидим новите данни. post_pred_new сега съдържа прогнозите за данни извън извадката. Това работи само ако наборът от клиенти, наблюдавани по време на обучението, е супернабор като този в данните извън извадката (което означава, че няма нови клиенти в новите данни).

Преквалификация

В класическия ML моделите могат да бъдат преобучени с нови данни, но това често включва започване от нулата и повторно оптимизиране на параметрите (разбира се, има и много модели като невронни мрежи, където това не е така).

Байесовите модели могат лесно да се актуализират с нови данни, като се използват същите байесови принципи. Задната от предишната тренировъчна сесия става предходна за новата тренировъчна сесия. Този процес се нарича последователно обучение.

Нека засега приемем, че винаги имаме един и същ набор от клиенти (ще видим как да се справим с нови клиенти по-късно). В този случай, когато генерираме данните, трябва да зададем prob_new на 0. Това, което сега трябва да направим, е да създадем нов модел с предишни стойности, равни на задните на стария модел. Първо получаваме точкови оценки за задната част на нашия „стар“ модел.

prior_beta_new = trace.posterior["beta"].mean().values
prior_cust_exp_new = trace.posterior["cust_exp"].mean(axis=0).mean(axis=0).values
prior_alpha_new = trace.posterior["alpha"].mean().values

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

with pm.Model() as new_model:

    obs = pm.MutableData('obs', df_new.spend.values)
    x = pm.MutableData('x', df_new.x.values)
    cust_id = pm.MutableData("cust_id", df_new.cust_id.values)

    sig = pm.HalfNormal("sig", sigma=10)
    beta = pm.Normal("beta", mu=prior_beta_new, sigma=10)
    alpha = pm.Normal("alpha", mu=prior_alpha_new, sigma=10)

    cust_exp = pm.Normal("cust_exp", mu=prior_cust_exp_new, sigma=5)
    y = pm.Normal("y", mu=cust_exp[cust_id] + beta*x, sigma=sig, observed=obs)
        
    trace_new = pm.sample(1000)

Разбира се, бихме могли да повтаряме този процес сега всеки път, когато постъпват нови данни. На практика, разбира се, ние също бихме превърнали кода по-горе във функция и бихме го увили в API, но принципите остават същите.

Работа с нови категории

Често има категорични характеристики, където постоянно ще виждаме нови стойности. Например, да кажем, че искаме да предвидим стойността на транзакцията въз основа на клиента. Една от възможностите би била да се използват характеристики на клиента, като възраст или доход. Но в нашия случай ние не разполагаме с тази информация. Единственото нещо, което имаме, е идентификатор на клиент, който свързва покупка с клиент, като например в таблицата по-долу.

Обикновено обработваме категориални характеристики чрез еднократно кодиране. Тоест създаваме една нова колона в нашата матрица на характеристиките за всяка отделна стойност, която имаме. Ако кардиналността на характеристиката е твърде висока (т.е. твърде много отделни стойности), този подход може да бъде твърде скъп, тъй като матрицата на характеристиките става много рядка. По-долу е даден пример за еднократно горещо кодиране на идентификатора на клиента за исторически данни.

oc = OneHotEncoder()
ex = pd.DataFrame(oc.fit_transform(df[['cust_id']]).toarray())
ex["cust_id"] = df["cust_id"]

В нашите ежедневни операции с данни клиентите посещават магазина и бихме могли лесно да трансформираме клиентски идентификатор в едно горещо представяне, както следва, и след това да изпълним прогноза.

oc.transform(np.array([[8]])).toarray()

>>> array([[0., 0., 0., 0., 0., 0., 0., 0., 1., 0.]])

Но тогава проблемът е, какво се случва, ако нов, невиждан клиент (напр. идентификатор на клиента = 10) влезе в магазина? Имахме само идентификатор на клиент от 0 до 9 в нашите данни за обучение, така че не можем да я представим с нашата матрица на характеристиките.

oc.transform(np.array([[10]])).toarray()

>>> ValueError: Found unknown categories [10] in column 0 during transform

След това ще трябва да монтираме повторно нашия едногорещ енкодер, което ще доведе до допълнителна колона за допълнителния клиент. Но това също означава, че трябва да преквалифицираме нашия модел, тъй като имаме допълнителна функция. Друг вариант би бил да маркирате всички клиенти, които не са били видени по време на обучението, като „неизвестни“, но това очевидно изхвърля много информация.

В байесовската настройка ние също трябва да направим някои корекции в нашия модел, за да посрещнем нови клиенти. По-конкретно, трябва да добавим коефициенти за нашите нови клиенти. Например, ако имаме 10 различни клиенти в нашите данни за обучение и след това се появят два нови, трябва да добавим два нови коефициента, за да ги съобразим. Следователно трябва да променим само един ред, когато създаваме нашия нов модел. И така, бихме добавили коефициенти към нашия модел, равни на броя на новите клиенти.

n_total = max(np.concatenate([df.cust_id.values, df_new.cust_id.values]))+1
n_old = df.cust_id.nunique()

n_new = n_total-n_old

# add uninformative priors for the new customers
prior_cust_exp_new = np.append(prior_cust_exp_new, [0]*n_new)

След това можем да направим извод върху модела както преди. Също така е важно да се отбележи, че в нашия случай ние приехме неинформативни предишни данни за всички клиенти. Ако имахме някаква причина да приемем, че даден клиент може да има по-висок/по-нисък разход (априори), бихме могли също така да зададем приоритета за коефициента по различен начин. Така че вместо да имате напр. [0,0,0] за трима нови клиенти може да имаме [3,0,-3], ако смятаме, че новият клиент 1 ще похарчи повече, вторият неутрален и третият по-малко от нормалното.

Във всеки случай, ние все още сме ефективни в смисъл, че трябва само да обучим нашия модел на нови, невиждани данни, когато сме в онлайн среда. Не се нуждаем от старите данни, когато преобучаваме модела, вместо това можем просто да дадем параметрите, които сме получили от стари серии.

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

Заключение

В тази публикация показах някои примери за това как можем да използваме байесови модели в производството и какви неща трябва да вземем предвид. Човек трябва винаги да разглежда техния специфичен бизнес контекст, за да види дали байесовото моделиране има смисъл. Въпреки че обикновено е по-скъпо в изчислителна гледна точка от класическия ML, може да има ситуации, в които последователното обучение се оказва много полезно, особено ако искаме да направим малки, постепенни промени в нашия модел.