План исследования
Более детальное описание тут.
Ссылка на данные: https://www.kaggle.com/uciml/breast-cancer-wisconsin-data/data
Описание признаков: https://archive.ics.uci.edu/ml/machine-learning-databases/breast-cancer-wisconsin/wdbc.names
import pandas as pd
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
import seaborn as sns
from tqdm import tqdm
from sklearn.model_selection import train_test_split, cross_val_score, learning_curve, StratifiedKFold, GridSearchCV
from sklearn.metrics import accuracy_score, precision_score
from sklearn.ensemble import RandomForestClassifier
from sklearn.manifold import TSNE
from sklearn.preprocessing import StandardScaler
pd.set_option('display.height', 1000)
pd.set_option('display.max_rows', 500)
pd.set_option('display.max_columns', 500)
pd.set_option('display.width', 1000)
df = pd.read_csv('data/data.csv', sep=',')
Признаки были получены из оцифрованных изображений (FNA) молочной железы. Они описывают характеристики ячеек клеток, присутствующих в изображении.
Наша задача бинарно классифицировать пациентов по типу опухоли - доброкачественная или злокачественная. Целевой признак "diagnosis", значения которого ("M" и "B") соответсвуют диагнозу ("Malignant" или "Benign").
Количество сэмплов: 569
Количество фич: 32 (ID, диагноз и 30 переменных в формате real, обозначающих характеристики опухоли)
В данных пропусков нет.
df.info()
Для каждой опухоли вычисляются десять вещественных признаков:
Mean, SE (standard error) и Worst этих признаков были рассчитаны для каждого изображения, в результате чего мы имеем 30 фич.
Например, поле 3 представляет собой средний радиус, поле 13 - Радиус SE, поле 23 является Worst Радиусом.
df.head()
Посмотрим на распределение целевого признака
print('Malignant:', len(df[df['diagnosis'] == 'M']))
print('Benign:', len(df[df['diagnosis'] == 'B']))
Проверим датасет на наличие пропущенных значений
df.isnull().sum()
Отлично, все значения присутствуют, кроме полностью нулевого признака, который мы позже удалим
Основная статистическая информация:
df.describe()
df.get_ftype_counts()
В датасете только один категориальный признак, который является целевым, все остальные числа с плавающей точкой
Представим переменную diagnosis в бинарном виде
df.diagnosis = df.diagnosis.replace({'M': 1, 'B': 0})
df.head()
Удалим столбец с пустыми значениями и идетификаторы пациентов
df.drop(columns=['Unnamed: 32', 'id'], inplace=True)
Более наглядное распределение целевой переменной:
sns.countplot(x=df['diagnosis'], palette="Set3")
plt.show()
Проверим наши данные на наличие выбросов и посмотрим на разницу значений между переменными:
plt.figure(figsize=[30, 30])
ax = sns.boxplot(data=df, palette="Set3")
ax.set_xticklabels(ax.get_xticklabels(), rotation=45, ha="right")
ax.set_xticklabels(ax.get_xticklabels(), size='xx-large')
plt.tight_layout()
plt.show()
Посмотрим на средний радиус в контексте целевой переменной
sns.boxplot(x='radius_mean', y=df['diagnosis'].replace({1: 'Malignant', 0: 'Benign'}), data=df, palette="Set3")
plt.show()
Видим, что в основном средний радиус опухоли значительно больше у людей со злокачественной опухолью.
Нужно проверить, есть ли отличия по другим признакам.
Построим pairplot для mean фич:
ax = sns.pairplot(data=df, hue='diagnosis',
vars=['radius_mean', 'texture_mean', 'perimeter_mean', 'area_mean',\
'smoothness_mean', 'compactness_mean', 'concavity_mean', \
'concave points_mean', 'symmetry_mean', 'fractal_dimension_mean'])
plt.figure(figsize=[30,30])
plt.show();
Для standart error:
ax = sns.pairplot(data=df, hue='diagnosis',
vars=['radius_se', 'texture_se', 'perimeter_se', 'area_se',\
'smoothness_se', 'compactness_se', 'concavity_se', \
'concave points_se', 'symmetry_se', 'fractal_dimension_se'])
plt.figure(figsize=[30,30])
plt.show();
Для worst признаков:
ax = sns.pairplot(data=df, hue='diagnosis',
vars=['radius_worst', 'texture_worst', 'perimeter_worst', 'area_worst',\
'smoothness_worst', 'compactness_worst', 'concavity_worst', \
'concave points_worst', 'symmetry_worst', 'fractal_dimension_worst'])
plt.figure(figsize=[30,30])
plt.show();
Попробуем теперь уменьшить размерность наших данных и разбить их на кластеры
scaler = StandardScaler()
%%time
df_scaled = scaler.fit_transform(df.drop(columns=['diagnosis']))
tsne = TSNE(random_state=22)
tsne_representation_full = tsne.fit_transform(df_scaled)
plt.scatter(tsne_representation_full[:, 0], tsne_representation_full[:, 1],
c=df['diagnosis'].map({0: 'blue', 1: 'orange'}));
plt.show()
Посмотрим на матрицу корреляций:
plt.figure(figsize=[30, 30])
ax = sns.heatmap(df.corr(), fmt = ".1f", cmap='YlGnBu', cbar = True, annot=True)
ax.set_xticklabels(ax.get_xticklabels(), size='xx-large')
ax.set_yticklabels(ax.get_yticklabels(), size='xx-large')
sns.set(font_scale=1.4)
plt.show();
Судя по boxplot'ам в данных присутствуют выбросы, пара признаков значительно отличаются от большинства по масштабу.
У _mean и _worst признаков на большинстве графиков видно четкое разбиение по целевому признаку. Снижение размерности данных с помощью TSN-e также показало два четких кластера с несущественным количеством аномалий (которые могут быть выбросами или ошибками в данных).
Подобный результат должен означать высокую точность предсказаний у модели.
На матрице корреляций оказалось много значений, стремящихся к единице, в основном потому, что некоторые признаки вычисялются друг из друга или имеют прямую зависимость между собой (например: radius_mean и radius_worst)
В качестве модели для классификации опухолей будем использовать случайный лес, т.к. данная модель нечувствительна к выбросам и может выдавать высокую точность без масштабирования и детальной настройки
y = df.diagnosis
X = df.drop(columns=['diagnosis'])
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=21)
rfc = RandomForestClassifier(n_jobs=-1, random_state=21)
rfc_params = {'max_depth': range(2, 10),
'n_estimators': [2, 20, 100],
'criterion': ['entropy', 'gini'],
'max_features': ['sqrt', None],
'min_samples_split' : range(2, 6),
'max_leaf_nodes' : [100, None]}
Найдем наилучшее сочетание гипер-параметров в модели
%%time
rfc_search = GridSearchCV(rfc, param_grid=rfc_params, n_jobs=-1)
rfc_search.fit(X_train, y_train)
rfc_search.best_params_
def plot_learning_curve(estimator, title, X, y, ylim=None, cv=None,
n_jobs=1, train_sizes=np.linspace(.1, 1.0, 5)):
'''
http://scikit-learn.org/stable/auto_examples/model_selection/plot_learning_curve.html
'''
plt.figure()
plt.title(title)
if ylim is not None:
plt.ylim(*ylim)
plt.xlabel("Training examples")
plt.ylabel("Score")
train_sizes, train_scores, test_scores = learning_curve(
estimator, X, y, cv=cv, n_jobs=n_jobs, train_sizes=train_sizes, scoring='roc_auc')
train_scores_mean = np.mean(train_scores, axis=1)
train_scores_std = np.std(train_scores, axis=1)
test_scores_mean = np.mean(test_scores, axis=1)
test_scores_std = np.std(test_scores, axis=1)
plt.grid()
plt.fill_between(train_sizes, train_scores_mean - train_scores_std,
train_scores_mean + train_scores_std, alpha=0.1,
color="r")
plt.fill_between(train_sizes, test_scores_mean - test_scores_std,
test_scores_mean + test_scores_std, alpha=0.1, color="g")
plt.plot(train_sizes, train_scores_mean, 'o-', color="r",
label="Training score")
plt.plot(train_sizes, test_scores_mean, 'o-', color="g",
label="Cross-validation score")
plt.legend(loc="best")
return plt
rfc_best = RandomForestClassifier(max_depth=4, max_features='sqrt', max_leaf_nodes=100, \
n_estimators=20, min_samples_split=2, random_state=21)
rfc_best.fit(X_train, y_train)
plt.figure(figsize=(8, 6))
plot_learning_curve(rfc_best, 'Random Forest', X_train, y_train, cv=3, n_jobs=-1);
plt.show()
Посмотрим на важность признаков при обучении
print('Feature ranking:')
for f in range(X_train.shape[1]):
print('%d. feature %s (%f)' % (f + 1, X_train.columns[f],
rfc_best.feature_importances_[f]))
Самыми важными признаками оказались radius_worst и perimeter_worst, забавно
Ради интереса попробуем обучить модель и сделать прогноз для двух выборок - для полной и для выборки только из двух признаков (radius_worst и perimeter_worst)
rfc_main_features = RandomForestClassifier(max_depth=4, max_features='sqrt', max_leaf_nodes=100, \
n_estimators=20, min_samples_split=2, random_state=21)
rfc_main_features.fit(X_train[['radius_worst', 'perimeter_worst']], y_train)
predictions_all = rfc_best.predict(X_test)
predictions_main_features = rfc_main_features.predict(X_test[['radius_worst', 'perimeter_worst']])
Распределение данных по целевому признаку неравномерно (37%), поэтому в качестве метрики будем использовать precision
round(precision_score(y_test, predictions_all), 3)
Для выборки со всеми признаками мы получили достаточно высокую точность (около 96%-99% в зависимости от данных)
round(precision_score(y_test, predictions_main_features), 3)
Обучение на всего лишь двух признаках (radius_worst и perimeter_worst) выдает метрику precision около 91%-93%
Похоже, что для высокого качества классификации опухолей достаточно иметь представление хотя бы об их размере
RandomForestClassifier обученный на тренировочной выборке выдает 96%-99% по метрике precision.
Наибольшее значение для классификации имеют только 2-3 признака, причиной этому может быть либо природа данных, либо их искусственность. Возможно, выборка слишком узкая, а в реальной жизни доброкачественная и злокачественная опухоли могут иметь одинаковый размер, но отличаться по другим признакам.
Также не ясно, как наша модель поведет себя на большом количестве данных, т.к. у нас в выборке меньше 600 сэмплов. Скорее всего модель потребуется переобучить, вследсвие чего метрика может снизиться.
Однако, по графику с валидационной кривой видно, что по мере увеличения выборки растет и точность.
Вывод: для использования в реальных условиях необходимо обеспечить модель необходимым количеством информации, после чего можно будет понять целесообразно ее использовать или нет.