<jupyter_start><jupyter_text>Машинное обучение в бизнесе. Итоговый проект.  Задача В основу работы взято соревнование для школьников, данные для которого подготовил Сбербанк. Цель моделирования - предсказать возраст клиента (точнее, одну из четырех возрастных групп) по его расходам.Страница соревнования: https://ods.ai/competitions/sberbank-sirius-lesson<jupyter_code>import dill
import numpy as np
import pandas as pd
# pipeline
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import Pipeline
# Классификатор
from lightgbm import LGBMClassifier
# Метрики
from sklearn.metrics import classification_report<jupyter_output><empty_output><jupyter_text>Исходные данные Данные заранее подготовлены и упакованы, иначе они занимают очень много места. Использованы только данные, помеченные как "train". Для модели это полный датасет.<jupyter_code>transactions = pd.read_pickle('data/transactions.pkl.zip')
transactions.sample(2)
# Указания на target сохранены отдельно
target = (
    pd.read_csv('data/train_target.csv', index_col='client_id')
    .rename(columns={'bins': 'target'})
)

target.sample(2)<jupyter_output><empty_output><jupyter_text>Train/Test Split В силу специфики датасета делить будем `client_id`, а затем собирать датасеты.<jupyter_code># custom train-test split
def tts(features, target, train_size=0.75, random_state=None):
    rng = np.random.default_rng(random_state)
    all_idx = features.index.unique()
    if train_size < 1:
        train_size = int(all_idx.size * train_size)
    test_size = all_idx.size - train_size
    train_idx = rng.choice(all_idx, train_size, replace=False, shuffle=False)
    test_idx = np.setdiff1d(all_idx, train_idx)
    assert (set(all_idx) == set(train_idx) | set(test_idx))
    X_train = features[features.index.isin(train_idx)]
    X_test = features[features.index.isin(test_idx)]
    y_train = target.loc[X_train.index.unique()].target
    y_test = target.loc[X_test.index.unique()].target
    assert np.all(X_train.index.unique() == y_train.index)
    assert np.all(X_test.index.unique() == y_test.index)
    return X_train, X_test, y_train, y_test
%%time
X_train, X_test, y_train, y_test = tts(transactions, target, random_state=2021)
X_test.to_pickle('data/X_test.pkl.zip')
y_test.to_pickle('data/y_test.pkl.zip')<jupyter_output>CPU times: user 7.16 s, sys: 365 ms, total: 7.53 s
Wall time: 7.53 s<jupyter_text>Preprocessing<jupyter_code># Небольшой помощник, запоминает имена индекса и столбцов
# Пригодится для работы через API
class StoreNamesPreprocessor(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        self.frame_names = {
            'index': X.index.name,
            'columns': X.columns.values.tolist()
        }
        return self
    
    def transform(self, X, y=None):
        return X<jupyter_output><empty_output><jupyter_text>Используем информацию о категориях транзакций ("группах"). Для этого необходим файл small_group_description.csv.<jupyter_code>small_group_description = (
    pd.read_csv('data/small_group_description.csv')
    .rename(columns={'small_group': 'small_group_desc',
                     'small_group_code': 'small_group'})
    .set_index('small_group')
)

small_group_description.sample(2)
# Упрощение кода: инкапсулируем данные о группах в базовый класс для наших трансформеров
class BaseTransformer(BaseEstimator, TransformerMixin):
    _all_small_groups = small_group_description.index.unique().values
    
    def fit(self, X, y=None):
        return self<jupyter_output><empty_output><jupyter_text>Feature Engineering<jupyter_code># Общая статистика по транзакциям клиентов
class StatsTransformer(BaseTransformer):
    def __init__(self, values='amount_rur',
                 aggfunc=('sum', 'mean', 'std', 'min', 'max')):
        self.values = values
        self.aggfunc = aggfunc
        
    def transform(self, X, y=None):
        return (
            X.filter([self.values])
            .groupby(level=0)[self.values]
            .agg(self.aggfunc)
            .loc[X.index.unique()]
        )
# Количество (или другая функция агрегации) транзакций по каждой из категорий
class GroupAggTransformer(BaseTransformer):
    def __init__(self, values, aggfunc):
        self.values = values
        self.aggfunc = aggfunc
    
    def fit(self, X, y=None):
        self._all_small_groups = np.sort(
            np.union1d(X['small_group'].unique(),
                       self._all_small_groups)
        )
        return self
    
    def transform(self, X, y=None):
        pivot_df = pd.pivot_table(X, index=X.index.name,
                                  values=self.values, columns='small_group',
                                  aggfunc=self.aggfunc,
                                  dropna=False, fill_value=0)
        for column in self._all_small_groups:
            if column not in pivot_df.columns:
                pivot_df[column] = np.zeros(pivot_df.shape[0], dtype=pivot_df.iloc[0,0].dtype)
        
        result = pivot_df.loc[X.index.unique(), self._all_small_groups]
        result.columns = [f"group_{col:03d}_{self.values}_{self.aggfunc}" for col in result.columns]
        
        return result       
# Применяет векторную функцию ко всем значениям
class ApplyFuncTransformer(BaseTransformer):
    def __init__(self, func=None):
        self.func = func

    def transform(self, X, y=None):
        return X.apply(self.func) if self.func is not None else X<jupyter_output><empty_output><jupyter_text>Pipeline Итак, в модель нужно взять следующие фичи:- общую статистику по транзакциям- количество дней с транзакциями по категориям- сумму транзакций (логарифм) по категориямПосле этого необходимо обучить классификатор.<jupyter_code># Упрощенный аналог FeatureUnion для pandas 
class FrameUnion(BaseEstimator, TransformerMixin):
    def __init__(self, transformer_list):
        self.transformer_list = transformer_list
        
    def fit(self, X, y=None):
        self._translist = [tr.fit(X=X) for tr in self.transformer_list]
        return self

    def transform(self, X, y=None):
        transformed = [tr.transform(X=X) for tr in self._translist]
        result = transformed[0]
        if len(transformed):
            for tr in transformed[1:]:
                result = result.join(tr, how='outer')
        return result
all_features = FrameUnion([
    StatsTransformer(),
    GroupAggTransformer(values='trans_date', aggfunc='nunique'),
    Pipeline([
        ('group_sum', GroupAggTransformer(values='amount_rur', aggfunc='sum')),
        ('log1p', ApplyFuncTransformer(np.log1p))
    ])
])

pipe = Pipeline([
    ('store_names', StoreNamesPreprocessor()),
    ('all_features', all_features),
    ('cls', LGBMClassifier(min_child_samples=200))
])
%%time
pipe.fit(X_train, y_train)
with open("app/app/models/pipeline.dill", "wb") as f:
    dill.dump(pipe, f)
with open("app/app/models/pipeline.dill", "rb") as f:
    pipe = dill.load(f)
%%time
y_pred = pipe.predict(X_test)
print(classification_report(y_test, y_pred))<jupyter_output>precision    recall  f1-score   support

           0       0.52      0.49      0.50      1897
           1       0.69      0.72      0.70      1800
           2       0.76      0.80      0.78      1912
           3       0.51      0.50      0.51      1891

    accuracy                           0.63      7500
   macro avg       0.62      0.63      0.62      7500
weighted avg       0.62      0.63      0.62      7500

CPU times: user 3.56 s, sys: 193 ms, total: 3.75 s
Wall time: 3.63 s
{'fraction_non_alphanumeric': 0.10521415270018622, 'fraction_numerical': 0.018754988028731046, 'mean_word_length': 3.188857938718663, 'pattern_counts': {'":': 0, '<': 28, '<?xml version=': 0, '>': 27, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 9, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '7', 'dir': 'jupyter-structured-clean-dedup', 'id': '530265', 'license': 'MIT', 'n_tokens_mistral': 2691, 'n_tokens_neox': 2722, 'n_words': 548, 'path': 'sber_client_age.ipynb', 'repo_name': 'avidbrain/ml_in_business', 'size': '7518'}