<jupyter_start><jupyter_text>1 - Читаем и препроцессим данные Прочитайте и проанализируйте данные, выберите турниры, в которых есть данные о составах команд и повопросных результатах (поле mask в results.pkl). Для унификации предлагаю:- взять в тренировочный набор турниры с dateStart из 2019 года; - в тестовый — турниры с dateStart из 2020 года.<jupyter_code>with open('results.pkl', 'rb') as f1, open('tournaments.pkl', 'rb') as f2, open('players.pkl', 'rb') as f3:
    source_results = pickle.load(f1)
    source_tournaments = pickle.load(f2)
    source_players = pickle.load(f3)
players_names = {v['id']: v['name'] + ' ' + v['surname'] for k, v in source_players.items()}
tournament_names = {v['id']: v['name']for k, v in source_tournaments.items()}

# Разделим турниры на трейн/тест
train_tournaments = set([v['id'] for k, v in source_tournaments.items() if v['dateStart'][:4] == '2019'])
test_tournaments = set([v['id'] for k, v in source_tournaments.items() if v['dateStart'][:4] == '2020'])
teams_dct = {}
results_train = {}
results_test = {}

for tournament_id, teams_list in source_results.items():
    tournament_results = {}
    for team in teams_list:
        team_mask = team.get('mask')
        team_members = [player['player']['id'] for player in team['teamMembers']]
        
        # Оставим только команды с игроками и бинарной маской ответов
        if team_mask is None or re.findall('[^01]', team_mask) or not team_members:
            continue
          
        team_id = team['team']['id']
        team_name = team['team']['name']
        teams_dct[team_id] = team_name
        
        tournament_results[team_id] = {}
        tournament_results[team_id]['mask'] = team_mask
        tournament_results[team_id]['players'] = team_members
    
    # Уберем турниры с разным числом вопросов и разделим результаты на трейн/тест
    if len(set(list(map(len, [team['mask'] for team in tournament_results.values()])))) == 1:  
        if tournament_id in train_tournaments:
            results_train[tournament_id] = tournament_results
        elif tournament_id in test_tournaments:
            results_test[tournament_id] = tournament_results<jupyter_output><empty_output><jupyter_text>2 - Baseline Постройте baseline-модель на основе линейной или логистической регрессии, которая будет обучать рейтинг-лист игроков. Замечания и подсказки:- повопросные результаты — это фактически результаты броска монетки, и их предсказание скорее всего имеет отношение к бинарной классификации;- в разных турнирах вопросы совсем разного уровня сложности, поэтому модель должна это учитывать; скорее всего, модель должна будет явно обучать не только силу каждого игрока, но и сложность каждого вопроса;- для baseline-модели можно забыть о командах и считать, что повопросные результаты команды просто относятся к каждому из её игроков. Будем обучать коэффициенты при игроках и вопросах, то есть X - OHE-вектор размерности (N, n_questions + n_players)<jupyter_code>train = []

max_question_id = 0

for tournament_id, teams in tqdm(results_train.items()):
    for team_id, team in teams.items():
        mask = np.array([np.int32(answer) for answer in team['mask']])
        players = team['players']
        questions = np.tile(np.arange(max_question_id, max_question_id + len(mask)), len(players))
        answers = np.array(np.meshgrid(players, mask)).T.reshape(-1, 2)
        answers = np.hstack([
            np.repeat(tournament_id, len(questions)).reshape(-1, 1),
            np.repeat(team_id, len(questions)).reshape(-1, 1),
            answers, 
            questions.reshape(-1, 1)]
        )
        train.append(answers)
        
    max_question_id += len(mask)
        
train = np.vstack(train).astype(np.int32)
train = pd.DataFrame(train, 
                     columns = ['tournament_id', 'team_id', 'player_id', 'answer', 'question_id'])
ohe = OneHotEncoder(handle_unknown='ignore')

X_tr = ohe.fit_transform(train[['player_id', 'question_id']])
y_tr = train['answer']
lr = LogisticRegression(random_state=42, n_jobs=-1)

lr.fit(X_tr, y_tr)<jupyter_output><empty_output><jupyter_text>Отранжируем игроков по коэффициенту в логистической регрессии<jupyter_code>unique_players = np.unique(train['player_id'])
unique_questions = np.unique(train['question_id'])
                    
rating = pd.DataFrame({'player_id': unique_players,
                       'strength': lr.coef_[0][:len(unique_players)]})
rating['name'] = rating['player_id'].map(players_names)
rating.sort_values(by='strength', ascending=False).head(20)<jupyter_output><empty_output><jupyter_text>Такой вот у нас получился рейтинг (топ 20). Если сравнить с официальным рейтингом, то становится ясно, что это не ноунеймы=)  3 - Оценка качества Качество рейтинг-системы оценивается качеством предсказаний результатов турниров. Но сами повопросные результаты наши модели предсказывать вряд ли смогут, ведь неизвестно, насколько сложными окажутся вопросы в будущих турнирах; да и не нужны эти предсказания сами по себе. Поэтому:- предложите способ предсказать результаты нового турнира с известными составами, но неизвестными вопросами, в виде ранжирования команд;- в качестве метрики качества на тестовом наборе давайте считать ранговые корреляции Спирмена и Кендалла (их можно взять в пакете scipy) между реальным ранжированием в результатах турнира и предсказанным моделью, усреднённые по тестовому множеству турниров. Силу команд оценим как вероятность того, что хотя бы один участник ответит верно на 1 вопрос (сложность вопроса учитывать не будем, проставим для этих признаков нолики)<jupyter_code>players_train = set(unique_players)
questions_train = set(unique_questions)
test = []
for tournament_id, teams in tqdm(results_test.items()):
    for team_id, team in teams.items():
        mask = np.array([np.int32(answer) for answer in team['mask']])
        for player_id in team['players']:
            # оставим только игроков из трейна 
            # может не совсем корректно и лучше подставлять среднюю силу, но по качеству это примерно одинаково
            if player_id not in players_train: 
                continue
            
            # -1 - фиктивно добавим вопросы, которых не было
            test.append((tournament_id, team_id, player_id, -1, sum(mask), len(mask))) 
        
test = np.vstack(test).astype(np.int32)
test = pd.DataFrame(test, 
                    columns = ['tournament_id', 'team_id', 'player_id', 'question_id', 'n_true', 'n_total'])
X_te = test[['player_id', 'question_id']]
X_te = ohe.transform(X_te)
preds = lr.predict_proba(X_te)[:, 1]
def compute_scores(data, preds):
    data['pred'] = preds
    data['score'] = data.groupby(['tournament_id', 'team_id'])['pred'].transform(lambda x: 1 - np.prod(1 - x))
    rating = data[['tournament_id', 'team_id', 'n_true', 'score']].drop_duplicates().reset_index(drop=True)
    
    # Считаем реальный рейтинг команд
    rating = rating.sort_values(by=['tournament_id', 'n_true'], ascending=False)
    rating['real_rank'] = rating.groupby('tournament_id')['n_true'].transform(lambda x: np.arange(1, len(x) + 1))
    
    # Считаем предсказанный рейтинг
    rating = rating.sort_values(by=['tournament_id', 'score'], ascending=False)
    rating['pred_rank'] = rating.groupby('tournament_id')['score'].transform(lambda x: np.arange(1, len(x) + 1))

    rating = rating.astype(np.int32)
    
    print(f"Корреляция Спирмана: {rating.groupby('tournament_id').apply(lambda x: stats.spearmanr(x['real_rank'], x['pred_rank']).correlation).mean()}")
    print(f"Корреляция Кендалла: {rating.groupby('tournament_id').apply(lambda x: stats.kendalltau(x['real_rank'], x['pred_rank']).correlation).mean()}")
compute_scores(test, preds)<jupyter_output>Корреляция Спирмана: 0.7719380041155093
Корреляция Кендалла: 0.6025156026312651<jupyter_text>Корреляции в норме!  4 - EM Теперь главное: ЧГК — это всё-таки командная игра. Поэтому:- предложите способ учитывать то, что на вопрос отвечают сразу несколько игроков; скорее всего, понадобятся скрытые переменные; не стесняйтесь делать упрощающие предположения, но теперь переменные “игрок X ответил на вопрос Y” при условии данных должны стать зависимыми для игроков одной и той же команды;- разработайте EM-схему для обучения этой модели, реализуйте её в коде;- обучите несколько итераций, убедитесь, что целевые метрики со временем растут (скорее всего, ненамного, но расти должны), выберите лучшую модель, используя целевые метрики. До этого мы считали, что если команда ответила на вопрос, то и игрок на него ответил. На самом деле это не так, и нам нужно оценить вероятность ответа игрока при условии "силы команды". "Силой команды" можно назвать, например, среднее число ответивших на вопрос игроков, но там образуются сложные вычисления с binominal poisson distribution. Предлагаю попробовать считать "силой команды", как и ранее, вероятность хотя бы одного игрока ответить на вопрос. Также предположим, что "сила команды", если игрок ответил верно, равна 1. Тогда:- P(player = 1 | team) = P(team | player = 1) * P(player = 1) / P(team) =  P(player = 1) / P(team)- Также будем считать, что P(player = 1 | team ) = 0, если команда ответила на вопрос неверноСоответсвенно, на E-шаге оцениваем P(player = 1 | team), а на M-шаге обучаем логистическую регрессию на этом таргете, на выходе получаем значения P(player = 1). P(team), как и ранее, оцениваем как вероятность того, что хотя бы 1 игрок ответит верно: P(team) = 1 - П[1 - P(player = 1)]<jupyter_code>def log_loss(y_true, y_pred):
    return - np.mean(y_true * np.log(y_pred) + (1 - y_true) * np.log(1 - y_pred))

class EMClassifier:
    
    def __init__(self, w=None, lr=25, n_iter=30, batch_size=5000, verbose=1):
        self.w = w
        self.lr = lr
        self.n_iter = n_iter
        self.batch_size = batch_size
        self.verbose = 1
        
    def _add_intercept(self, X):
        return sp.hstack((np.ones((X.shape[0], 1)), X), format='csr')
    
    def _init_w(self, dim):
        self.w = np.random.randn(dim)
        
    def _E_step(self, data, preds):
        team_strength = pd.DataFrame({'team_id': data['team_id'],
                                      'question_id': data['question_id'], 
                                      'team_strength': 1 - preds})
        team_strength = team_strength.groupby(['team_id', 'question_id']).agg({'team_strength': 'prod'}).reset_index()
        team_strength['team_strength'] = 1 - team_strength['team_strength']
        team_strength = data[['team_id', 'question_id']].merge(team_strength)
        y = np.clip(preds / team_strength['team_strength'], 0, 1).values # переведем к вероятностям
        y[data['answer'] == 0] = 0
        return y
        
    def _M_step(self, X, y):
        # Обучаем LogReg батчевым градиентным спуском, чтобы выскакивать из локальных минимумов
        min_loss = np.inf
        indices = np.arange(X.shape[0])
        for _ in range(100):
            indices = np.random.permutation(indices)
            for batch_idx in np.array_split(indices, len(indices) // self.batch_size):
                x_batch, y_batch = X[batch_idx], y[batch_idx]
                grad = x_batch.T.dot(self.predict(x_batch) - y_batch) / len(y_batch)
                self.w -= self.lr * grad
                
            cur_loss = log_loss(y, self.predict(X))
            if min_loss - cur_loss < 1e-6:
                break
                
            min_loss = cur_loss
                
    def fit(self, X_tr, train_data, X_te=None, test_data=None):
        X_tr = self._add_intercept(X_tr)
        if self.w is None or len(self.w) != X_tr.shape[1]:
            self._init_w(X_tr.shape[1])
        
        for iter_ in tqdm(range(self.n_iter)): 
            preds = self.predict(X_tr)
            y = self._E_step(train_data, preds)
            self._M_step(X_tr, y)
            if self.verbose is not None and X_te is not None and test_data is not None and iter_ % self.verbose == 0:
                compute_scores(test_data, self.predict(X_te))
                         
    def predict(self, X):
        if self.w is None:
            raise ValueError('Model is not fitted yet!')
        if len(self.w) != X.shape[1]:
            X = self._add_intercept(X)
        return expit(X.dot(self.w)) 
# Инициализируем нашей обученной моделькой, чтобы не ждать вечность
w_init = np.hstack([lr.intercept_, lr.coef_[0]])
em_classifier = EMClassifier(w_init)
# Обучим 30 эпох
em_classifier.fit(X_tr, train, X_te, test)<jupyter_output>3%|▎         | 1/30 [02:05<1:00:26, 125.05s/it]<jupyter_text>В целом, получилось добиться неплохого прироста относительно бейзлайна. Посмотрим еще раз рейтинг игроков.<jupyter_code>rating = pd.DataFrame({'player_id': unique_players,
                       'strength': em_classifier.w[1:1 + len(unique_players)]})
rating['name'] = rating['player_id'].map(players_names)
rating['questions_count'] = rating['player_id'].map(train.groupby('player_id')['question_id'].count())
rating.sort_values(by='strength', ascending=False).head(50)<jupyter_output><empty_output><jupyter_text>Как мы видим, в топе сейчас действительно стало много игроков, которые сыграли очень мало вопросов.  5 - Рейтинг вопросов А что там с вопросами? Постройте “рейтинг-лист” турниров по сложности вопросов. Соответствует ли он интуиции (например, на чемпионате мира в целом должны быть сложные вопросы, а на турнирах для школьников — простые)? Если будет интересно: постройте топ сложных и простых вопросов со ссылками на конкретные записи в базе вопросов ЧГК (это чисто техническое дело, тут никакого ML нету). Сложность турнира посчитаем как среднюю сложность вопроса - возьмем средние коэффициенты нашей модели.<jupyter_code>q_rating = dict(zip(unique_questions, em_classifier.w[-len(unique_questions):]))

train['difficulty'] = train['question_id'].map(q_rating)
train['tournament_name'] = train['tournament_id'].map(tournament_names)
tournaments_rating = train[['tournament_name', 'question_id', 'difficulty']].drop_duplicates()
tournaments_rating = tournaments_rating.groupby('tournament_name')['difficulty'].mean().sort_values().reset_index()
# Самые сложные турниры по версии модели (сверху вниз)
tournaments_rating.head(30)
# Самые простые турниры по версии модели (снизу ввверх)
tournaments_rating.tail(30)<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.11212990830825226, 'fraction_numerical': 0.01224889759921607, 'mean_word_length': 3.8286583305170665, 'pattern_counts': {'":': 0, '<': 29, '<?xml version=': 0, '>': 27, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 2, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '7', 'dir': 'jupyter-structured-clean-dedup', 'id': '107026', 'license': 'MIT', 'n_tokens_mistral': 5336, 'n_tokens_neox': 5665, 'n_words': 1244, 'path': 'homework-03.ipynb', 'repo_name': 'x-sile/made_ml', 'size': '14287'}