Что такое разреженная матрица?

Вам не нужно заниматься наукой о данных задолго до того, как вы услышите об алгоритме XGBoost, включая все соревнования Kaggle, в которых он с большим успехом использовался. Также нет недостатка в отличных онлайн-руководствах (в том числе На пути к науке о данных) о том, как начать использовать этот алгоритм. Однако есть удивительная особенность XGBoost, которая часто упускается из виду и, к сожалению, отсутствует в большинстве учебных пособий: способность XGBoost принимать разреженную матрицу в качестве входных данных. Если вы не знакомы с этой структурой данных, почему она так полезна или как ее использовать в XGBoost, вы попали по адресу! Поначалу использование разреженных матриц может показаться пугающим, но к концу статьи я покажу вам, насколько это просто. Я уверен, что вы будете использовать их в своих собственных проектах по науке о данных в кратчайшие сроки, особенно если у вас есть наборы данных с высокой кардинальностью.

Вариант использования с набором данных высокой кардинальности

Разреженная матрица — это структура данных, очень эффективная для хранения табличной информации, в которой многие столбцы содержат значения NULL (вы увидите другие определения, в которых говорится, что разреженные матрицы состоят в основном из нулей, что является обычным определением, но в этом статьи, матрицы, которые я создаю, технически имеют NULL или отсутствующие значения). Сначала это может показаться очень странным. В конце концов, зачем вам вообще использовать набор данных с большим количеством значений NULL? Все сводится к категориальным переменным с высокой кардинальностью и тому, как мы должны их преобразовать, прежде чем мы сможем использовать XGBoost.

Кардинальность — это слово за 10 долларов, которое описывает категориальный (нечисловой) признак данных, который имеет множество возможных значений. Допустим, у вас есть данные о том, где люди жили, включая их почтовый индекс. Несмотря на то, что почтовые индексы в Соединенных Штатах содержат 5 цифр, это действительно функция категориальных данных (например, 73301 не меньше 73302 из-за значения самого почтового индекса). Существуют тысячи различных почтовых индексов, так что это действительно функция данных с высокой кардинальностью. Одна из стратегий сделать эту функцию доступной для чтения с помощью XGBoost (который принимает только числовые функции) — это горячее кодирование. Здесь вы создаете индикаторную переменную для каждого возможного почтового индекса (например, один столбец называется lives_in_73301, другой называется lives_in_73302 и т. д.) и заполняете эти столбцы единицами или 0s, где 1 означает, что человек живет в этом почтовом индексе. Вы скоро поймете, что это приводит к структуре данных, которая заполнена в основном нулями. Повторите это упражнение с несколькими другими столбцами с высокой кардинальностью, и теперь вы должны понимать, что ваши данные быстро станут очень разреженными.

Однако, если вы попытаетесь сохранить эти разреженные данные во фрейме данных pandas (или любой другой плотной табличной структуре, если на то пошло), вы можете очень быстро столкнуться с проблемами памяти. Настолько быстро, что вам захочется начать все сначала и попытаться найти способ уменьшить кардинальность ваших столбцов. Но не сдавайся! В эти столбцы может быть встроена очень ценная информация, которую вы отбрасываете ради уменьшения кардинальности и использования памяти. Не поймите меня неправильно, фреймы данных pandas великолепны, и я часто использую их в своей работе. Однако они плохо подходят для хранения разреженных данных, особенно когда набор данных, о котором идет речь, довольно велик или имеет множество функций.

Введите структуру данных разреженной матрицы. В частности, матрица CSR, которая означает «сжатая разреженная строка» (существуют и другие способы разреженного кодирования матрицы, но в приведенном ниже конвейере используется структура CSR). Эта структура данных использует тот факт, что большая часть матрицы будет содержать недостающую информацию. Он работает, сохраняя только элементы, отличные от NULL, вместе с их местоположением. Когда большая часть информации в матрице отсутствует, структура матрицы CSR будет использовать гораздо меньше памяти, чем ее плотные аналоги, в которых также хранятся отсутствующие значения и их расположение.

Следует отметить, что у использования разреженных матриц есть недостатки, главный из которых заключается в том, что их не так просто использовать, как другие структуры данных, такие как фреймы данных pandas для исследовательского анализа данных. Их основная цель — эффективно хранить разреженные данные. Поэтому я рекомендую полагаться на другие инструменты для изучения и понимания вашего набора данных. Когда вы будете готовы построить конвейер модели, только тогда переходите к использованию разреженной матрицы, если это уместно.

Преобразование данных в длинный формат

Для этого упражнения мы будем использовать Набор данных о 130 больницах США по диабету за 1999–2008 годы и собираемся построить модель для прогнозирования повторной госпитализации в течение 30 дней. Вы также можете загрузить этот набор данных самостоятельно и выполнить приведенный ниже код Python, чтобы следовать ему (после установки необходимых пакетов, если вы еще этого не сделали). Многие поля в этом наборе данных потенциально полезны, но здесь мы сосредоточимся в основном на нескольких полях с высокой кардинальностью. В частности, мы собираемся включить:

  • повторно принят (используется для разработки целевой переменной)
  • пол (по общему признанию, малое число элементов, но пол часто является отличным предиктором многих заболеваний, поэтому мы сохраним его).
  • возраст(10-летний возрастной диапазон пациента, опять же низкая кардинальность, но, как и пол, возраст часто является отличным предиктором состояния здоровья).
  • diag_1 (основной диагноз).
  • diag_2 (дополнительный диагноз, если применимо).
  • diag_3 (третичный диагноз, если применимо)
  • encounter_id (идентификатор встречи или входа).

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

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

Теперь давайте импортируем наши данные и сохраним только те столбцы, которые нужны для этого упражнения.

import pandas as pd, xgboost as xgb
from scipy.sparse import csr_matrix
from pandas.api.types import CategoricalDtype
from sklearn.model_selection import train_test_split
from xgboost.sklearn import XGBClassifier
import shap
df = pd.read_csv('diabetic_data.csv', low_memory = False)
ex_df = df[[
    'encounter_id'
    ,'readmitted'
    ,'gender'
    ,'age'
    ,'diag_1'
    ,'diag_2'
    ,'diag_3'
]]
ex_df.readmitted.unique()

Изучая столбец remitted, мы видим, что есть три возможных значения: NO, ›30 и ‹30, представляющих без реадмиссии, реадмиссии через 30 дней и реадмиссии до 30 дней соответственно (мне непонятно, как этот набор данных обрабатывает случаи, когда реадмиссия происходит ровно через 30 дней, но мы проигнорируем этот сценарий и продолжим). Мы создадим новый столбец с именем _admit_lt_30days, индикаторную переменную, которая будет заполнена 1, если пациент был позже госпитализирован в течение 30 дней после выписки, и 0 в противном случае.

ex_df = ex_df.assign(_admit_lt_30days = 0)
ex_df.loc[ex_df.readmitted == '<30','_admit_lt_30days'] = 1
ex_df = ex_df.drop('readmitted', axis = 1)

Почему подчеркивание перед именем переменной _admit_lt_30days? Мы доберемся до этого в ближайшее время, обещаю!

Преобразование данных в широкую и разреженную матрицу

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

  • переменная(имя сохраняемой переменной).
  • значение(значение рассматриваемой переменной).
  • «Диагностика V58» (встреча для других и неуточненных процедур и долечивания).

Например, для столбца «пол» мы создадим две переменные: gender_f и gender_m. Значение этих столбцов будет равно 1, если пациент во время госпитализации является женщиной или мужчиной соответственно.

Это может показаться противоречащим всему, что вы когда-либо знали об алгоритмах машинного обучения, основанных на структурированных данных. В конце концов, разве вы не должны иметь одну строку для каждого наблюдения? С данными в длинной форме у нас будет пять переменных для каждой встречи. Однако это всего лишь промежуточный шаг, который поможет нам позже построить широкую и разреженную матрицу, где каждое допущение действительно будет представлено одной строкой.

Сейчас мы структурируем данные в этом длинном формате, используя встроенную функцию melt в пандах.

numeric_df = ex_df[[
    'encounter_id', '_admit_lt_30days'
]].melt(id_vars = 'encounter_id')
text_df = ex_df[[
    'encounter_id', 'gender', 'age', 'diag_1', 'diag_2', 'diag_3'
]].melt(id_vars = 'encounter_id')
text_df.variable = text_df.variable + '_' + text_df.value
#Remove special characters from variable names
text_df.variable = \
    text_df.variable.str.replace('\[|\)', '', regex = True)
text_df = text_df.assign(value = 1)
tall_df = numeric_df.append(text_df)

Построение модели XGBoost

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

Чтобы преобразовать длинные данные в широкие и разреженные данные, мы применим преобразование к большому набору данных, которое очень похоже на сводную функцию в пандах, хотя мы будем преобразовывать результат в Вместо этого матричная структура данных CSR. Каждая запись в столбце переменная станет отдельным столбцом. Значением каждого элемента в матрице CSR будет число в столбце value и строке encounter_id. Последним столбцом в наборе данных будет encounter_id с одной строкой для каждого идентификатора. Обратите внимание, что для некоторых комбинаций encounter_id и переменной в новой разреженной матрице не будет соответствующего значения. Это будет особенно верно для столбцов, содержащих диагностические коды. В этих ситуациях значение в матрице CSR по существу является значением NULL, хотя на практике в матрице CSR вообще ничего не сохраняется. После этого преобразования каждое наблюдение будет иметь одну строку, и данные будут готовы для алгоритма XGBoost.

encounter_c = \
    CategoricalDtype(sorted(tall_df.encounter_id.unique()), ordered=True)
var_c = \
    CategoricalDtype(sorted(tall_df.variable.unique()), ordered=True)
row = \
    tall_df.encounter_id.astype(encounter_c).cat.codes
col = \
    tall_df.variable.astype(var_c).cat.codes
sparse_matrix = \
    csr_matrix(
        ( tall_df["value"], (row, col) )
        , shape = ( encounter_c.categories.size, var_c.categories.size )
    )
#Everything after the first column is a feature
X = sparse_matrix[:,1:]
#The first column is the target variable
Y = pd.DataFrame(sparse_matrix[:,0].astype(int).todense())
X_train, X_test, Y_train, Y_test = \
    train_test_split(X,Y, test_size=0.2, random_state=888)

Обратите внимание, что при создании var_c мы сортируем столбцы перед применением функции CategoricalDtype. Это приводит к алфавитному расположению всех имен переменных. Вспомните, что мы поместили знак подчеркивания перед нашей целевой переменной, а знак подчеркивания сортируется перед любой строчной буквой. Итак, самый первый столбец нашей разреженной матрицы содержит нашу целевую переменную, а остальная часть матрицы содержит наши признаки. Вот почему мы поместили подчеркивание перед нашей целевой переменной, чтобы мы сразу знали, где ее искать, когда мы приводим данные в разреженной матрице (то есть в самом первом столбце матрицы CSR), даже если мы добавим или удалите другие функции модели позже.

Выводы из модели с использованием значений SHAP

Теперь мы готовы построить нашу модель XGBoost. Мы собираемся пропустить настройку гиперпараметров и сразу же погрузиться в построение модели, поскольку основное внимание уделяется тому, чтобы начать использовать разреженные матрицы в XGBoost.

xgb_model = \
    xgb.XGBClassifier(
        objective='binary:logistic'
        ,booster='gbtree'
        ,tree_method='auto'
        ,eval_metric='logloss'
        ,n_jobs=4
        ,max_delta_step=0
        ,random_state=888
        ,verbosity=1
    )
xgb_model.fit(X_train, Y_train)

Мы также можем получить важность функции с помощью gain. Мы видим, что прирост достаточно высок по отдельным видам диагнозов. То есть эти особенности считаются очень важными, когда речь идет о прогнозировании вероятности реадмиссии.

#Get the feature importance by gain and sort
#This has "dummy" names like f0, f1, etc. that have little meaning
#However, we will update them with meaningful names later.
d2 = xgb_model.get_booster().get_score(importance_type='gain')
feature_by_gain_noname = \
    pd.DataFrame(
        data=list(d2.items())
        ,columns=['feature','importance_by_gain']
    ).sort_values('importance_by_gain', ascending = False)
#Get the index values of the features
to_index = \
    feature_by_gain_noname['feature'].str.slice(1,).astype(int).tolist()
#Create a data frame with the feature names and sort
names_in_order = var_c.categories[1:][to_index].to_frame()
names_in_order.columns = ['feature']
names_in_order.index = range(len(names_in_order.index))
#Create a data frame that does not have the dummy name column
by_gain = feature_by_gain_noname['importance_by_gain'].to_frame()
by_gain.columns = ['importance_by_gain']
by_gain.index = range(len(by_gain.index))
#Join the data frame with names to the gain values
feature_by_gain = names_in_order.join(by_gain)
feature_by_gain.head(10)

Дальнейшие шаги и другие советы

Хотя усиление является ценным показателем, оно не говорит нам, является ли переменная положительным или отрицательным предиктором. Например, увеличивает или уменьшает ли первичный диагноз V58 вероятность повторной госпитализации? Это невозможно сказать, используя только показатель усиления.

Затем с помощью пакета SHAP мы можем посмотреть, какие переменные оказали наибольшее влияние на прогнозирование 30-дневной повторной госпитализации. Недостатком является то, что SHAP не может принимать разреженную матрицу в качестве входных данных (помните, как я сказал, что с фреймами данных pandas часто легче работать?). В качестве обходного пути мы можем уменьшить количество измерений в нашем исходном наборе данных, включив только те функции, которые были выбраны с использованием реализации разреженной матрицы. Другой вариант, который можно рассмотреть для гораздо больших наборов данных, — ограничить анализ SHAP только подмножеством данных.

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

filter_df = \
    tall_df.loc[
        tall_df.variable.isin(feature_by_gain.feature)|(tall_df.variable == '_admit_lt_30days'),
    ]
filter_df_pivot = \
    filter_df.pivot_table(
        index='encounter_id'
        ,columns='variable'
        ,values='value'
    ).rename_axis(None, axis=1)
df = filter_df_pivot.reset_index()
df = df.drop(columns='encounter_id')
feature_final = \
    list(df.columns[~df.columns.isin(['_admit_lt_30days'])])
X = df.loc[:,feature_final]
Y = df.loc[:,'_admit_lt_30days']
X_train, X_test, Y_train, Y_test = \
    train_test_split(X,Y, test_size=0.1, random_state=652)
model = xgb_model.fit(X_train, Y_train)
explainer = shap.Explainer(model, X_test)
shap_values = explainer(X_test)

Очень естественный и распространенный вопрос состоит в том, чтобы объяснить, почему модель оценивала определенные наблюдения определенным образом. Например, давайте посмотрим на пациентов, у которых, согласно модели, вероятность повторной госпитализации была выше 50%. Мы могли бы захотеть понять, почему модель присваивала этим госпитализациям такую ​​высокую вероятность повторного госпитализации.

score_pred_score = xgb_model.predict_proba(X_test)
score_pred_score_df = \
    pd.DataFrame(score_pred_score, columns=['proba_0', 'proba_1'])
score_pred_score_df.loc[score_pred_score_df.proba_1 > .5,]

Наблюдение с наибольшей вероятностью реадмиссии имеет значение индекса 8782 и вероятность реадмиссии примерно 65%, согласно нашей модели. Мы можем взглянуть на график водопада SHAP, чтобы понять, почему это признание было оценено таким образом.

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

  • Диагноз 150 (Злокачественные новообразования органов пищеварения и брюшины).
  • Диагноз 427 (Нарушения сердечного ритма).
  • Я намеренно сделал этот пример простым ради этого урока. Используя этот конвейер или некоторые его модификации, вы сможете использовать поля с высокой кардинальностью уникальным способом, который снижает нагрузку на память без потери информации. Но это лишь малая часть силы этой техники.

    Допустим, вы хотели вернуться и включить в эту модель функцию медицинской специальности, чтобы посмотреть, поможет ли это предсказать повторную госпитализацию. К счастью, это очень легко сделать! Просто перейдите к разделу, где вы преобразовали данные в высокий формат, и добавьте медицинскую специальность в список включенных переменных, используя существующий код в качестве шаблона. Этот новый элемент будет проходить через остальную часть вашей модели, вплоть до подгонки модели в XGBoost и создания выходных данных SHAP. Допустим, вы хотели создать переменную на основе первых трех символов кода диагноза (который группирует похожие диагнозы вместе, хотя обратите внимание, что используемый пример набора данных, возможно, уже сделал это для недиабетических диагнозов). Опять же, очень легко! Вам нужно только перейти к разделу, где вы конвертируете данные в длинный формат и создаете столбец на основе первых трех символов диагностического кода. Как правило, добавление новых функций в этот конвейер или удаление ненужных функций из конвейера не вызывает затруднений. Вам просто нужно выяснить индекс наблюдения (encounter_id в этой статье), имя переменной и значение для ввода. Затем вы добавляете эту новую информацию в свой существующий длинный фрейм данных, а остальное поступает оттуда.

    В качестве последнего примечания напомним, что мы закодировали диагностическую информацию для этого примера. Однако вы можете поместить в столбец значение другие значения, кроме 1. Предположим, что вместо ограниченной информации, доступной нам в этом наборе данных, мы знали, сколько раз пациент получил диагноз в прошлом году. Если пациенту был поставлен диагноз с кодом диагноза 250,8 десять раз за последний год, значение может быть заполнено 10 вместо 1. Сюда можно включить все виды значений, которые, по вашему мнению, могут быть прогностическими, от потраченных долларов, дней с момента постановки диагноза, количества посещений и так далее. Делая это, вы встраиваете ценную информацию в свою разреженную матрицу, которую XGBoost затем может использовать для построения лучшей модели (например, может быть, пациенты, которым часто ставят определенный диагноз, больше подвержены риску повторной госпитализации, и, возможно, модель может уловить этот шаблон). , и т. д. ).

    Я надеюсь, что вы нашли это полезным, и я желаю вам удачи, когда вы включаете разреженные матрицы в свои собственные модели!

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

encounter_id (уникальный идентификатор для каждой госпитализации)

Использование разреженных матриц в XGBoost