Описание проекта

Подготовьте прототип модели машинного обучения для «Цифры». Компания разрабатывает решения для эффективной работы промышленных предприятий.

Модель должна предсказать коэффициент восстановления золота из золотосодержащей руды. В вашем распоряжении данные с параметрами добычи и очистки.

Модель поможет оптимизировать производство, чтобы не запускать предприятие с убыточными характеристиками.

Вам нужно:

  1. Подготовить данные;
  2. Провести исследовательский анализ данных;
  3. Построить и обучить модель.

Чтобы выполнить проект, обращайтесь к библиотекам pandas, matplotlib и sklearn. Вам поможет их документация.

(... загрузка данных и библиотек ...)

1. Подготовка данных

Посмотрим какие признаки есть в трейне, но нет в тесте

In [3]:
set(train.columns) - set(test.columns)
Out[3]:
{'final.output.concentrate_ag',
 'final.output.concentrate_au',
 'final.output.concentrate_pb',
 'final.output.concentrate_sol',
 'final.output.recovery',
 'final.output.tail_ag',
 'final.output.tail_au',
 'final.output.tail_pb',
 'final.output.tail_sol',
 'primary_cleaner.output.concentrate_ag',
 'primary_cleaner.output.concentrate_au',
 'primary_cleaner.output.concentrate_pb',
 'primary_cleaner.output.concentrate_sol',
 'primary_cleaner.output.tail_ag',
 'primary_cleaner.output.tail_au',
 'primary_cleaner.output.tail_pb',
 'primary_cleaner.output.tail_sol',
 'rougher.calculation.au_pb_ratio',
 'rougher.calculation.floatbank10_sulfate_to_au_feed',
 'rougher.calculation.floatbank11_sulfate_to_au_feed',
 'rougher.calculation.sulfate_to_au_concentrate',
 'rougher.output.concentrate_ag',
 'rougher.output.concentrate_au',
 'rougher.output.concentrate_pb',
 'rougher.output.concentrate_sol',
 'rougher.output.recovery',
 'rougher.output.tail_ag',
 'rougher.output.tail_au',
 'rougher.output.tail_pb',
 'rougher.output.tail_sol',
 'secondary_cleaner.output.tail_ag',
 'secondary_cleaner.output.tail_au',
 'secondary_cleaner.output.tail_pb',
 'secondary_cleaner.output.tail_sol'}

В тесте у нас нет признаков output, так как это целевые признаки.

Так же в тесте нет признаков calculation для этапа rougher, видимо эти данные рассчитываются позднее и не доступны во время процесса.

In [4]:
a = [1,2,3,4,5]
b = [2,3,4,6,8]
set(b) - set(a)
Out[4]:
{6, 8}

Проверим расчет recovery в train:

  • Вычислим recovery из сырых значений, проверим сколько из них совпало с предоставленными
  • Вычислим MAE для рассчитанных значений и исходых
In [5]:
def recovery(C, F, T):
    
    numerator = (C*(F-T))
    denominator =(F*(C-T))
    
    rec = numerator / denominator * 100
    
    #nonzero_denominator = denominator != 0   
    #rec[~nonzero_denominator] = 0
    
    # так как мы не застрахованы от очень больших и очень маленьких значений, то заполним их
    rec[rec<0] = np.nan
    rec[rec>100] = np.nan
    return rec
In [6]:
t,f,c = train['rougher.output.tail_au'], train['rougher.input.feed_au'], train['rougher.output.concentrate_au']
rec = recovery(c, f, t)
right_recovery_sum = np.isclose(train['rougher.output.recovery'], rec).sum()
right_recovery_sum, train.shape[0]-right_recovery_sum
Out[6]:
(14287, 2573)
In [7]:
rec.describe()
Out[7]:
count    14287.000000
mean        82.394201
std         15.096808
min         -0.000000
25%         79.818372
50%         85.235997
75%         90.131691
max        100.000000
dtype: float64

Совпало 14287 рассчетных с иходными, не совпало 2573, возможно там пропуски.

Для расчета MAE, заполним пропуски нулями =)

In [8]:
mean_absolute_error(train['rougher.output.recovery'].interpolate(method='time'),
                    rec.interpolate(method='time'))
Out[8]:
9.35113328539498e-15
In [9]:
rec.isna().sum()
Out[9]:
2573

Как мы видим средняя абсолютная ошибка достаточно мала, считаем считаем что recovery посчитан верно

Посмотрим на пропуски в данных в трейне и тесте:

In [11]:
def show_na(df):
    na_info = (df.isna() | df.isnull()).sum()
    res = (pd.concat([na_info / df.shape[0], na_info], axis=1,  keys=['percent', 'abs'])
           .sort_values('percent', ascending=False))
    return res


show_na(train).head(10)
Out[11]:
percent abs
rougher.output.recovery 0.152610 2573
rougher.output.tail_ag 0.133452 2250
rougher.output.tail_sol 0.133393 2249
rougher.output.tail_au 0.133393 2249
secondary_cleaner.output.tail_sol 0.117794 1986
rougher.input.floatbank11_xanthate 0.112930 1904
final.output.recovery 0.090214 1521
primary_cleaner.input.sulfate 0.077521 1307
primary_cleaner.input.depressant 0.074852 1262
rougher.calculation.au_pb_ratio 0.073665 1242
In [12]:
show_na(test).head(10)
Out[12]:
percent abs
rougher.input.floatbank11_xanthate 0.060280 353
primary_cleaner.input.sulfate 0.051571 302
primary_cleaner.input.depressant 0.048497 284
rougher.input.floatbank10_sulfate 0.043887 257
primary_cleaner.input.xanthate 0.028347 166
rougher.input.floatbank10_xanthate 0.021004 123
rougher.input.feed_sol 0.011441 67
rougher.input.floatbank11_sulfate 0.009392 55
rougher.input.feed_rate 0.006831 40
secondary_cleaner.state.floatbank3_a_air 0.005806 34

Пропуски в тесте не значительны

Пропуски в трейне более значитиельны, большая часть из них в целевых признаках .output., так же в rougher.output.recovery пропусков 2573 что совпадает с количеством несовпавших расчетных значений

Пока не будем обрабатывть пропуски, при машинном обучении на кросс-валидации проверим какой способ заполнения или удаления пропусков покажет себя лучше

Подготовка данных

Для подготовки данных к машинному обучению

  1. Удалим из train признаки которых нет в тесте
  2. Разделим данные не целевой признак и все остальные
  3. Так же разделим датасет на 2 части rougher и cleaner, так как процессы флотации и очистки не зависимы, то и предсказывать их тоже логично независимо.

Возможно предсказзывать recovery вообще не верный путь, так как он зависит от одного из обучающих параметров, может лучше для каждого процесса предсказыать output.concentrate и output.tail и затем на их основе уже считать recovery.

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

In [13]:
X_train = train[test.columns]

rougher_cols = X_train.columns.str.contains('rougher')
X_train_rougher = X_train.loc[:, rougher_cols]
X_test_rougher = test.loc[:, rougher_cols]
y_train_rougher = train[['rougher.output.tail_au', 'rougher.output.concentrate_au']]

final_cols = X_train.columns.str.contains('cleaner')
X_train_cleaner = X_train.loc[:, final_cols]
X_test_cleaner = test.loc[:, final_cols]
y_train_cleaner = train[['final.output.tail_au', 'final.output.concentrate_au']]

2. Анализ данных

Построим распределения концентраций металов на выходе каждого процесса и в хвостах

In [14]:
process = ['rougher.input.feed',
           'rougher.output.concentrate',
           'primary_cleaner.output.concentrate',
           'final.output.concentrate']

process_tail = ['rougher.input.feed',
                'rougher.output.tail',
                'primary_cleaner.output.tail',
                'final.output.tail']

metals = ['au', 'ag', 'pb']

fig, axs = plt.subplots(1, len(process), figsize=(20, 6), constrained_layout=True)
fig.suptitle('Metal concentrations in output by stage', fontsize=24)

for stage, ax in zip(process, axs):
    ax.set_title(stage)
    for metal in metals:        
        cols = train.columns.str.contains(stage+'_'+metal)
        sns_ax = sns.distplot(train.loc[:, cols].dropna(), label=metal, ax=ax)    
plt.legend()

fig, axs = plt.subplots(1, 4, figsize=(20, 6), constrained_layout=True)
fig.suptitle('Metal concentrations in tail by stage', fontsize=24)

for stage, ax in zip(process_tail, axs):
    ax.set_title(stage)
    for metal in metals:        
        cols = train.columns.str.contains(stage+'_'+metal)
        sns_ax = sns.distplot(train.loc[:, cols].dropna(), label=metal, ax=ax)   
plt.legend()

plt.show()

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

Так же отмети различную кноцентрацию металлов в хвостовых отвалах в после различных этапов

Доролнительно посторим графики для концентрации кадого металла на разны процессах на выходе и в хвосте

In [15]:
fig, axs = plt.subplots(1, len(metals), figsize=(20, 6), constrained_layout=True)
fig.suptitle('Metal concentrations in output by metal', fontsize=24)

for metal, ax in zip(metals, axs):
    ax.set_title(metal)
    for stage in process:
        cols = train.columns.str.contains(stage+'_'+metal)
        sns_ax = sns.distplot(train.loc[:, cols].dropna(), label=stage, ax=ax)   
plt.legend()

fig, axs = plt.subplots(1, len(metals), figsize=(20, 6), constrained_layout=True)
fig.suptitle('Metal concentrations in tail by metal', fontsize=24)

for metal, ax in zip(metals, axs):
    ax.set_title(metal)
    for stage in process_tail:
        cols = train.columns.str.contains(stage+'_'+metal)
        sns_ax = sns.distplot(train.loc[:, cols].dropna(), label=stage, ax=ax)   
plt.legend()

plt.show()

Отметим, что концентрация золота по мере очистки значительно вырастает, так же вырастают концентрации серебра и свинца но не так значительно.

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

Проверим размер гранул сырья в трейне и в тесте

In [16]:
sns.distplot(train['rougher.input.feed_size'].dropna(), label='train')
sns.distplot(test['rougher.input.feed_size'].dropna(), label='test')
plt.legend()
Out[16]:
<matplotlib.legend.Legend at 0x7fd8bd7dcb38>

Как мы видим распределения очень похожи

Исследуем суммарню концерацию в разных процессах в трейне и в тесте

PS не очень понимаю зачем это нужно, видимо что бы найти большое количество нулевых значений и что-то с ними сделать

In [17]:
fig, axs = plt.subplots(1, len(process), figsize=(20, 6), constrained_layout=True)
fig.suptitle('Summary metal concentrations in train and test by process', fontsize=24)

for stage, ax in zip(process, axs):
    ax.set_title(stage)
    sum_train = train[stage+ '_ag'] + train[stage+ '_au'] + train[stage+ '_pb']
    try:
        sum_test = test[stage+ '_ag'] + test[stage+ '_au'] + test[stage+ '_pb']
        sns.distplot(sum_test.dropna(), label='test', ax=ax) 
    except KeyError:
        pass
    sns.distplot(sum_train.dropna(), label='train', ax=ax) 

plt.legend()
plt.show()

3. Модель

Перейдем к машинному обучению, стратегия следующая:

  1. Готовим пайплайн: Заполнение пропусков -> Скалер(для регрессии) -> Модель
  2. Готовим параметры для поиска по сетке
  3. Заполним пропуски в целевой переменной и удалим нули из нее. только для train
  4. Делаем поиск по сетке, выбираем лучшую модель для флотации по метрике sMAPE
  5. То же самое для очистки
  6. Прогоняем пайплайн с лушими параметрами по всем данным, получаем предсказания для теста выходной и хвостовой концентраций
  7. Расчитываем recovery, для каждой стадидии.
  8. Подглядываем в full и смотрим конечное значение метрики
In [19]:
pipe = Pipeline([
    ('imp', SimpleImputer(missing_values=np.nan)),
    ('scaler', StandardScaler()),
    ('model', RandomForestRegressor(n_estimators=100, random_state=SEED))
])

params = [
    {
        'imp__strategy': ['mean', 'median', 'most_frequent'],
        'model': [RandomForestRegressor(n_estimators=10, random_state=SEED)],
        'model__max_features': np.linspace(0.1, 1, 10)
    }, {
        'imp__strategy': ['mean', 'median', 'most_frequent'],
        'model': [LinearRegression()]
    }, {
        'imp__strategy': ['mean', 'median', 'most_frequent'],
        'model': [Ridge(random_state=SEED)],
        'model__alpha': np.logspace(-3, 1, 10)
    }, {
        'imp__strategy': ['mean', 'median', 'most_frequent'],
        'model': [Lasso(random_state=SEED)],
        'model__alpha': np.logspace(-3, 1, 10)
    }
]
In [20]:
#переделал на заполнение интерполяцией
def fill_target_nan(y):
    y = y.interpolate(method='time')
    return y


def drop_target_zeros(X, y):
    y = y[(y != 0).all(1)]  
    X = X.loc[y.index, :]
    return X, y


print('Shapes before:')
print(X_train_rougher.shape, y_train_rougher.shape,
      X_train_cleaner.shape, y_train_cleaner.shape)

y_train_rougher = fill_target_nan(y_train_rougher)
y_train_cleaner = fill_target_nan(y_train_cleaner)

X_train_rougher, y_train_rougher = drop_target_zeros(X_train_rougher, y_train_rougher)
X_train_cleaner, y_train_cleaner = drop_target_zeros(X_train_cleaner, y_train_cleaner)        
                
print('Shapes after:')
print(X_train_rougher.shape, y_train_rougher.shape,
      X_train_cleaner.shape, y_train_cleaner.shape)       
Shapes before:
(16860, 22) (16860, 2) (16860, 30) (16860, 2)
Shapes after:
(15313, 22) (15313, 2) (15038, 30) (15038, 2)
In [21]:
#cv = KFold(n_splits=5, shuffle=False, random_state=SEED)
cv =TimeSeriesSplit(n_splits=3)
# возможно тут надо применить TimeSeriesSplit
# но из услови задачи не понятно, насколько процессы зависимы от времени и их длительность

grid_rougher = GridSearchCV(pipe, param_grid=params, cv=cv, scoring=neg_smape, n_jobs=-1)
In [22]:
temp = np.ones(train.shape[0])
plt.plot(pd.Series(data=temp, index=train.index), 'bo', c='blue', label='train index')

temp = np.zeros(test.shape[0])
plt.plot(pd.Series(data=temp, index=test.index), 'bo', c='red', label='test index')

plt.title('Train and test index')
plt.xticks(rotation=90)
plt.legend()
plt.show()
/opt/conda/lib/python3.7/site-packages/pandas/plotting/_matplotlib/converter.py:103: FutureWarning: Using an implicitly registered datetime converter for a matplotlib plotting method. The converter was registered by pandas on import. Future versions of pandas will require you to explicitly register matplotlib converters.

To register the converters:
	>>> from pandas.plotting import register_matplotlib_converters
	>>> register_matplotlib_converters()
  warnings.warn(msg, FutureWarning)
In [23]:
%%time
grid_rougher.fit(X_train_rougher, y_train_rougher)
CPU times: user 1min 52s, sys: 25.4 s, total: 2min 17s
Wall time: 2min 18s
Out[23]:
GridSearchCV(cv=TimeSeriesSplit(max_train_size=None, n_splits=3),
             error_score='raise-deprecating',
             estimator=Pipeline(memory=None,
                                steps=[('imp',
                                        SimpleImputer(add_indicator=False,
                                                      copy=True,
                                                      fill_value=None,
                                                      missing_values=nan,
                                                      strategy='mean',
                                                      verbose=0)),
                                       ('scaler',
                                        StandardScaler(copy=True,
                                                       with_mean=True,
                                                       with_std=True)),
                                       ('model',
                                        RandomForestRegressor(boots...
                                          random_state=21, selection='cyclic',
                                          tol=0.0001, warm_start=False)],
                          'model__alpha': array([1.00000000e-03, 2.78255940e-03, 7.74263683e-03, 2.15443469e-02,
       5.99484250e-02, 1.66810054e-01, 4.64158883e-01, 1.29154967e+00,
       3.59381366e+00, 1.00000000e+01])}],
             pre_dispatch='2*n_jobs', refit=True, return_train_score=False,
             scoring=make_scorer(sMAPE, greater_is_better=False), verbose=0)
In [24]:
grid_rougher.best_params_, grid_rougher.best_score_
Out[24]:
({'imp__strategy': 'mean',
  'model': Lasso(alpha=0.021544346900318832, copy_X=True, fit_intercept=True,
        max_iter=1000, normalize=False, positive=False, precompute=False,
        random_state=21, selection='cyclic', tol=0.0001, warm_start=False),
  'model__alpha': 0.021544346900318832},
 -0.2837030162412595)
In [25]:
grid_cleaner = GridSearchCV(pipe, param_grid=params, cv=cv, scoring=neg_smape, n_jobs=-1)
In [26]:
%%time
grid_cleaner.fit(X_train_cleaner, y_train_cleaner)
/opt/conda/lib/python3.7/site-packages/sklearn/linear_model/coordinate_descent.py:475: ConvergenceWarning: Objective did not converge. You might want to increase the number of iterations. Duality gap: 36.780756186679355, tolerance: 9.402729774037981
  positive)
CPU times: user 3min 13s, sys: 28.6 s, total: 3min 42s
Wall time: 3min 42s
Out[26]:
GridSearchCV(cv=TimeSeriesSplit(max_train_size=None, n_splits=3),
             error_score='raise-deprecating',
             estimator=Pipeline(memory=None,
                                steps=[('imp',
                                        SimpleImputer(add_indicator=False,
                                                      copy=True,
                                                      fill_value=None,
                                                      missing_values=nan,
                                                      strategy='mean',
                                                      verbose=0)),
                                       ('scaler',
                                        StandardScaler(copy=True,
                                                       with_mean=True,
                                                       with_std=True)),
                                       ('model',
                                        RandomForestRegressor(boots...
                                          precompute=False, random_state=21,
                                          selection='cyclic', tol=0.0001,
                                          warm_start=False)],
                          'model__alpha': array([1.00000000e-03, 2.78255940e-03, 7.74263683e-03, 2.15443469e-02,
       5.99484250e-02, 1.66810054e-01, 4.64158883e-01, 1.29154967e+00,
       3.59381366e+00, 1.00000000e+01])}],
             pre_dispatch='2*n_jobs', refit=True, return_train_score=False,
             scoring=make_scorer(sMAPE, greater_is_better=False), verbose=0)
In [27]:
grid_cleaner.best_params_, grid_cleaner.best_score_
Out[27]:
({'imp__strategy': 'mean',
  'model': Lasso(alpha=1.2915496650148828, copy_X=True, fit_intercept=True, max_iter=1000,
        normalize=False, positive=False, precompute=False, random_state=21,
        selection='cyclic', tol=0.0001, warm_start=False),
  'model__alpha': 1.2915496650148828},
 -0.17534833465197444)
In [28]:
input_au = test['rougher.input.feed_au'].fillna(0)

pipe_rougher = grid_rougher.best_estimator_

if type(pipe_rougher.steps[2][1]) is type(RandomForestRegressor()):    
    pipe_rougher.steps[2][1].n_estimators = 100 #с увеличением числа деревьев качество должно улучшаться
    
pipe_rougher.fit(X_train_rougher, y_train_rougher)

y_pred_rougher_tail, y_pred_rougher_conc = pipe_rougher.predict(X_test_rougher).T
rougher_recovery = recovery(y_pred_rougher_conc, input_au, y_pred_rougher_tail)

smape_rougher = sMAPE(full.loc[X_test_rougher.index, 'rougher.output.recovery'].interpolate(method='time'),
                      rougher_recovery.interpolate(method='time'))
smape_rougher
Out[28]:
0.10257891008840601
In [29]:
pipe_cleaner = grid_cleaner.best_estimator_

if type(pipe_cleaner.steps[2][1]) is type(RandomForestRegressor()):    
    pipe_cleaner.steps[2][1].n_estimators = 100 #с увеличением числа деревьев качество должно улучшаться
    
pipe_cleaner.fit(X_train_cleaner, y_train_cleaner)

y_pred_cleaner_tail, y_pred_cleaner_conc = pipe_cleaner.predict(X_test_cleaner).T
cleaner_recovery = recovery(y_pred_cleaner_conc, input_au, y_pred_cleaner_tail)

smape_cleaner = sMAPE(full.loc[X_test_rougher.index, 'final.output.recovery'].fillna(0),
                      cleaner_recovery.fillna(0))
smape_cleaner
Out[29]:
0.1348133876901321
In [30]:
final_smape = 0.25*smape_rougher + 0.75*smape_cleaner
final_smape
Out[30]:
0.1267547682897006

PS Пришло еще несколько идей:

  1. Предсказывать для rougher все остальные output и подавать их как фичи в пайплайн для cleaner
  2. Возможно сделать отдельные модели для primari cleaner и second cleaner