Files
ML/task11/[Research]_KNN_[2025_2026].ipynb
T
2025-11-12 11:34:34 +03:00

2.1 MiB
Raw Blame History

Машинное обучение. ВМК МГУ

Практическое задание 2. Knn: классификация и регрессия

Уровень: Исследовательский (Research)

О формате сдачи

🔷 При решении ноутбука используйте данный шаблон

✅ Можно добавлять новые ячейки любых типов
❌ Не нужно удалять текстовые ячейки c разметкой частей ноутбука и формулировками заданий

🔷 При оценивании задач учитывается код

✅ Задания, в которых необходим код, обычно помечаются фразами "Your code here"/"Ваш код" и аналогичными
❌ Ответы на вопросы без сопутствующего кода оцениваются в 0 баллов
❌ Наличе работоспособного кода в ноутбуке, если на сказано иного, обязательно

🔷 При оценивании задач учитываются выводы

✅ Задания, в которых необходимы выводы, обычно помечаются фразами Вывод"/"Ответ на вопрос"/"Ваш текст" и аналогичными
✅ Обычно выводы подразумевают под собой текстовый ответ (можно писать markdown, latex).
✅ Сопутствующие изображения, графики, таблички - приветствуются!
❌ При отсутствии выводов задание не засчитается на полный балл

Примерное время выполнения (execution time/время выполнения, если нажать run all) всех ячеек ноутбука при правильной реализации: 10 минут

Подготовка рабочей среды

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

После установки нужных версий, возможно, нужно перезагрузить среду (runtime), но скорее всего вам это не понадобится

На скачивание файла и установку понадобится не более 5 минут.

Важно!

Устанавливать нужные версии нужно каждый раз, когда создается новый рантайм. Например, если вы 2 часа подряд делаете это задание, то подготовить библиотеки достаточно 1 раз. Но если вы, например, начали в понедельник, затем закрыли/выключили ноутбук, то при продолжении в среду, вам нужно будет запустить рантайм заново и следовательно заново установить библиотеки.

Важно! Если вы предпочитаете делать практические задания на своем личном ноутбуке, то проверьте, что вы установили рабочее окружение в соответствии с гайдом

In [3]:
#! curl https://raw.githubusercontent.com/MSU-ML-COURSE/ML-COURSE-25-26/refs/heads/master/requirements/requirements.txt -o ./requirements_2025_26_for_colab_small.txt
! pip install -q -r ./requirements_2025_26_for_colab_small.txt

Проверим версию библиотеки:

In [4]:
import catboost
assert(catboost.__version__ == '1.2.8')

Внимание! Перед выполнение данного задания рекомендуем сначала погрузиться в [Base] ноутбук по основам sklearn, нормализации и кросс-валидации, так как данные техники и понятия будут активно использоваться в данном ноутбук

Теперь можно приступать к выполнению задания! :)


Часть 1. Нормализация признакового пространства

В данном части задания исследуем, как нормализация (скейлинг) данных влияет на разделимость данных

In [5]:
from sklearn.preprocessing import StandardScaler, MinMaxScaler
In [6]:
import numpy as np
import seaborn as sns
import pickle

from matplotlib import pyplot as plt


plt.rcParams["figure.figsize"] = (5,5)

Загрузим файл с данными data.pkl:

In [7]:
!gdown 1acbP4PNmu11rkBzozvIc3bowWaQL7iFe

Загрузим данные в память. В переменной X будут храниться признаковые описания объектов, в переменной y~- метки классов

In [10]:
with open('data.pkl', 'rb') as file:
    X, y = pickle.load(file)
In [12]:
def plot_data_points(X, labels, xlim, ylim):
    g = sns.scatterplot(x=X[:, 0], y=X[:, 1], hue=labels)
    g.set(xlim=xlim, ylim=ylim)
    plt.xlabel('x_1')
    plt.ylabel('x_2')
    plt.grid()

Визуализируем наши данные

In [13]:
plot_data_points(X, y, xlim=(-15, 15), ylim=(-15, 15))

Как видно, наши данные идеально разделяются прямой x=0

Функция plot\_knn\_bound принимает на вход объекты X, метки классов y, метод нормализации признаков scaler, число соседей n\_neighbors и границы рисунка xlim, ylim. Функция обучает KNN классификатор с числом соседей n\_neighbors и визуализирует разделяющую поверхность для классов, полученную с помощью обученного KNN классификатора.

  • Если вы не можете различить выбранные нами цвета, то измените список цветов в переменных cmap_light и cmap_bold. Актуальный список возможных цветов находится здесь
In [16]:
from matplotlib.colors import ListedColormap
from sklearn import neighbors, datasets

def plot_knn_bound(X, y, scaler=None, n_neighbors=10, xlim=(-15, 15), ylim=(-20, 20)):
    h = 0.05

    cmap_light = ListedColormap(['C0', 'orange', 'cyan', 'green'][:np.unique(y).shape[0]])
    cmap_bold = ['C0', 'orange', 'c', 'darkgreen'][:np.unique(y).shape[0]]

    x_min, x_max = xlim
    y_min, y_max = ylim
    xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
                         np.arange(y_min, y_max, h))
    grid = np.c_[xx.ravel(), yy.ravel()]

    X_scaled = X 
    if scaler is not None:
        grid = scaler.transform(grid)
        X_scaled = scaler.transform(X)

    clf = neighbors.KNeighborsClassifier(n_neighbors, algorithm='brute')
    clf.fit(X_scaled, y)

    Z = clf.predict(grid)

    Z = Z.reshape(xx.shape)
    plt.contourf(xx, yy, Z, cmap=cmap_light)

    sns.scatterplot(x=X[:, 0], y=X[:, 1], hue=y,
                    palette=cmap_bold, alpha=1.0, edgecolor="black")
    plt.xlabel('x_1')
    plt.ylabel('x_2')
    plt.title('Разделющие поверхности алгоритма {}-NN'.format(n_neighbors))
    plt.grid()
    plt.show()

Нарисуем разделющие поверхности $1$-NN и $10$-NN

In [17]:
plot_knn_bound(X, y, n_neighbors=1)
plot_knn_bound(X, y, n_neighbors=10)

Задание 1.1 (кросс-проверка, 1,5 балла)

Объясните, почему наблюдается сильное отклоенние разделяющей поверхности от прямой x_1=0 при значениях x_2 < -10 и x_2 > 10. Дайте строгое математическое обоснование наблюдаемого явления, показывающее, что в Евклидовом пространстве данная проблема решается нормализацией (скейлингом) признаков.

Ваше обоснование тут, можно использовать LaTeX: потому что в евклидовом расстоянии вклад каждой координаты равнозначен только до множителя; если значения x_2 в обучающей выборке имеют большой разброс или отличаются между классами больше, чем значения x_1, то слагаемое (Δx_2)^2 доминирует в расстоянии и «перетягивает» биссектор ближайших соседей. В результате локальные биссектрисы (и, в сумме, граница принятия решения 1-NN) оказываются наклонены или искривлены для больших x_2


Задание 1.2 (кросс-проверка, 2 балла)

Пусть дано произвольное число точек на плоскости. Представим, что каждая точка - это отдельный класс. Пусть на данной выборке был обучен 1-NN классификатор. Чем с геометрической точки зрения являются разделяющие поверхности этого классификатора? Требуется математически строго, однозначно и с полным обоснованием определить геометрическое место точек разделяющих поверхностей.

В качестве примера ниже представлена визуализация для четырех точек

In [18]:
X = np.random.randn(4, 2)
y = np.arange(4)
plot_knn_bound(X, y, n_neighbors=1, xlim=(-3, 3), ylim=(-3, 3))

Ваше обоснование тут, можно использовать LaTeX: Разделяющие поверхности 1-NN на плоскости — это границы клеток Вороного для данного множества точек: они равны объединению участков перпендикулярных биссектрис отрезков между парами точек, причём вершины — точки, равносторонне отстоящие от трёх (и более) сайтов (их центры описанных окружностей).

Часть 2. Кросс-валидация

Чтобы закрепить понимание устройства кросс-валидации предлагаем вам запрограммировать данный метод самостоятельно

Задание 2.1 (unit-tests, 3 балла)

В модуле cross_val.py реализуйте функции

  • kfold\_split, которая реализует генерацию индексов обучающей и валидационной выборок для кросс-валидации
  • knn\_cv\_score, которая реализует кросс-валидацию для $KNN-$модели

Примеры запусков функций можно увидеть в открытых тестах.


Часть 3. Работа с текстовыми данными

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

  • В качестве метрики качества будем использовать accuracy\_score.

Accuracy - это доля верно классифицированных объектов, среди всех объектов выборки.

In [19]:
from sklearn.datasets import fetch_20newsgroups
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
In [20]:
X_train, y_train = fetch_20newsgroups(subset='train', return_X_y=True, random_state=42)
X_test, y_test = fetch_20newsgroups(subset='test', return_X_y=True, random_state=42)
In [21]:
print(X_train[0])
From: lerxst@wam.umd.edu (where's my thing)
Subject: WHAT car is this!?
Nntp-Posting-Host: rac3.wam.umd.edu
Organization: University of Maryland, College Park
Lines: 15

 I was wondering if anyone out there could enlighten me on this car I saw
the other day. It was a 2-door sports car, looked to be from the late 60s/
early 70s. It was called a Bricklin. The doors were really small. In addition,
the front bumper was separate from the rest of the body. This is 
all I know. If anyone can tellme a model name, engine specs, years
of production, where this car is made, history, or whatever info you
have on this funky looking car, please e-mail.

Thanks,
- IL
   ---- brought to you by your neighborhood Lerxst ----





In [22]:
X_train = np.array(X_train, dtype=object)
In [23]:
X_test = np.array(X_test, dtype=object)
In [24]:
y_train = np.array(y_train)
y_test = np.array(y_test)

Как мы видим, объектами обучающей выборки являются тексты. Сырые текстовые данные не могут служить обучающими примерами для алгоритма KNN. Перед использованием KNN текстовые данные переменной длины необходимо отобразить в числовой вектор фиксированной размерности.

Работа с текстовыми данными проходит в несколько этапов.

  • Токенизация. Текстовая строка делится на токены. Как правило, в качестве токенов выступают отдельные слова и знаки препинания.

  • Нормализация. Приведение к нижнему регистру, удаление стоп-слов, удаление пунктуации, удаление редких и частотных слов и т.д.

  • Векторизация. Фиксируется словарь токенов V по обучающей выборке. Текст представлется в виде вектора длиной |V|, где каждый признак есть функция от частоты встречаемости токена в тексте и в обучающей выборке.

В данном задании будет использовано два вида векторизации.

  • Первый, CountVectorizer, сопоставляет каждому токену частоту встречаемости этого токена в тексте документа.

  • Второй, TfidfVectorizer, сопоставляет каждому токену частоту встречаемости этого токена в тексте документа умноженную на величину обратно пропорциональную частоте встречаемости токена во всех документах обучающей выборки. Подробне про tf-idf предлагаем изучить самостоятельно, например, здесь.

Полезные ссылки

Более подробно про работу с текстами можно прочитать в статье на Хабре и в NLTK Book.

В sklearn все векторизаторы автоматически токенизируют текст и приводят его к нижнему регистру. Параметры max\_df и min\_df позволяют убрать из словаря наиболее частые и наиболее редкие токены. Параметр stop\_words позволяет убрать из словаря стоп-слова (малоинформативные слова для данного языка, например, союзы).

In [25]:
count_vec = CountVectorizer(max_df=0.8, min_df=10, max_features=1000, stop_words='english')
tf_idf = TfidfVectorizer(max_df=0.8, min_df=10, max_features=1000, stop_words='english')

Задание 3.1 (кросс-проверка, 0,5 баллa)

Найдите оптимальные параметры обучения модели. Осуществлять перебор параметров следует по заданной ниже сетке.

  • Используйте реализованные вами функции kfold\_split, knn\_cv\_score. В качестве метрики используйте accuracy\_score. 3х фолдов для кросс-валидации будет достаточно.
In [26]:
from sklearn.metrics import accuracy_score

parameters = {
    'n_neighbors': [i for i in range(1, 11)],
    'metrics': ['cosine'],
    'weights': ['uniform', 'distance'],
    'normalizers': [(count_vec, 'CountVectorizer'), (tf_idf, 'TfidfVectorizer')]
}

Если вы все сделаете правильно, то запущенная вами кросс-валидация будет работать примерно 7 минут. Дождитесь ее завершения, не закрывайте и не перезагружайте этот colab ноутбук.

In [30]:
from cross_val import knn_cv_score, kfold_split
result = knn_cv_score(X_train, y_train, parameters, accuracy_score, kfold_split(X_train.shape[0], 3), neighbors.KNeighborsClassifier)
result
Out [30]:
{('CountVectorizer', 1, 'cosine', 'uniform'): np.float64(0.672087658236065),
 ('CountVectorizer', 1, 'cosine', 'distance'): np.float64(0.672087658236065),
 ('CountVectorizer', 2, 'cosine', 'uniform'): np.float64(0.6110139528291619),
 ('CountVectorizer', 2, 'cosine', 'distance'): np.float64(0.6722644460023516),
 ('CountVectorizer', 3, 'cosine', 'uniform'): np.float64(0.6174660970088653),
 ('CountVectorizer', 3, 'cosine', 'distance'): np.float64(0.6675804372619494),
 ('CountVectorizer', 4, 'cosine', 'uniform'): np.float64(0.6152563436671689),
 ('CountVectorizer', 4, 'cosine', 'distance'): np.float64(0.6666965921674022),
 ('CountVectorizer', 5, 'cosine', 'uniform'): np.float64(0.6185263080537139),
 ('CountVectorizer', 5, 'cosine', 'distance'): np.float64(0.6643980465607984),
 ('CountVectorizer', 6, 'cosine', 'uniform'): np.float64(0.6142833782286147),
 ('CountVectorizer', 6, 'cosine', 'distance'): np.float64(0.6595370157118955),
 ('CountVectorizer', 7, 'cosine', 'uniform'): np.float64(0.6131343983530804),
 ('CountVectorizer', 7, 'cosine', 'distance'): np.float64(0.6559130305425707),
 ('CountVectorizer', 8, 'cosine', 'uniform'): np.float64(0.6038539779918916),
 ('CountVectorizer', 8, 'cosine', 'distance'): np.float64(0.6522894906234525),
 ('CountVectorizer', 9, 'cosine', 'uniform'): np.float64(0.604472524265902),
 ('CountVectorizer', 9, 'cosine', 'distance'): np.float64(0.648135376497482),
 ('CountVectorizer', 10, 'cosine', 'uniform'): np.float64(0.5998765813295434),
 ('CountVectorizer', 10, 'cosine', 'distance'): np.float64(0.6455722350969835),
 ('TfidfVectorizer', 1, 'cosine', 'uniform'): np.float64(0.6936529067480152),
 ('TfidfVectorizer', 1, 'cosine', 'distance'): np.float64(0.6936529067480152),
 ('TfidfVectorizer', 2, 'cosine', 'uniform'): np.float64(0.6346121200480795),
 ('TfidfVectorizer', 2, 'cosine', 'distance'): np.float64(0.6936529067480152),
 ('TfidfVectorizer', 3, 'cosine', 'uniform'): np.float64(0.6405339477973659),
 ('TfidfVectorizer', 3, 'cosine', 'distance'): np.float64(0.6917972913602056),
 ('TfidfVectorizer', 4, 'cosine', 'uniform'): np.float64(0.6429204420369062),
 ('TfidfVectorizer', 4, 'cosine', 'distance'): np.float64(0.6895880770056014),
 ('TfidfVectorizer', 5, 'cosine', 'uniform'): np.float64(0.6401806066070069),
 ('TfidfVectorizer', 5, 'cosine', 'distance'): np.float64(0.6828707746106897),
 ('TfidfVectorizer', 6, 'cosine', 'uniform'): np.float64(0.6398272654166478),
 ('TfidfVectorizer', 6, 'cosine', 'distance'): np.float64(0.683401184777992),
 ('TfidfVectorizer', 7, 'cosine', 'uniform'): np.float64(0.6392082973266522),
 ('TfidfVectorizer', 7, 'cosine', 'distance'): np.float64(0.6788934013825628),
 ('TfidfVectorizer', 8, 'cosine', 'uniform'): np.float64(0.6343471493066424),
 ('TfidfVectorizer', 8, 'cosine', 'distance'): np.float64(0.6742089708261753),
 ('TfidfVectorizer', 9, 'cosine', 'uniform'): np.float64(0.6307235390848599),
 ('TfidfVectorizer', 9, 'cosine', 'distance'): np.float64(0.6723531210961516),
 ('TfidfVectorizer', 10, 'cosine', 'uniform'): np.float64(0.6277182642290952),
 ('TfidfVectorizer', 10, 'cosine', 'distance'): np.float64(0.6687294874401477)}

Задание 3.2 (кросс-проверка, 0,5 балл)

Какой метод предобработки данных в среднем дает наилучший результат? Почему?

In [31]:
tfidf = [v for k,v in result.items() if k[0] == 'TfidfVectorizer']
count = [v for k,v in result.items() if k[0] == 'CountVectorizer']
print(f'TfidfVectorizer - {np.mean(tfidf):.2f}, CountVectorizer {np.mean(count):.2f}')
TfidfVectorizer - 0.66, CountVectorizer 0.64

Ваш ответ тут:

  • TfidfVectorizer - 0.66
  • CountVectorizer 0.64

Задание 3.3 (кросс-проверка, 1 балл)

Начертите график (line plot) зависимости метрики качества от числа соседей. Метрику следует усреднить по всем параметрам, кроме числа соседей. Сделайте выводы о наблюдаемых зависимостях (возможно будет полезным ознакомиться с названиями классов)

In [32]:
num_neighbours = [k[1] for k,v in result.items()]
num_neighbours = np.unique(num_neighbours)
mean_vals = [np.mean([result[key] for key, val in result.items() if key[1] == k]) for k in num_neighbours]

plt.figure(figsize = [16, 8])
plt.plot(num_neighbours, mean_vals)
plt.ylabel('Точность')
plt.xlabel('Число соседей')
plt.title('Зависимость метрики качества от числа соседей')
plt.grid()

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

Задание 3.4 (кросс-проверка, 1,5 баллa)

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

In [33]:
maxim = max(result, key = lambda x: result[x])

X_train_vec = tf_idf.fit_transform(X_train)
X_test_vec = tf_idf.transform(X_test)

model = neighbors.KNeighborsClassifier(1, weights = 'uniform', metric = 'cosine')
model.fit(X_train_vec, y_train)
y_pred = model.predict(X_test_vec)
print(maxim, result[maxim])
accuracy_score(y_test, y_pred)
Out [33]:
('TfidfVectorizer', 1, 'cosine', 'uniform') 0.6936529067480152
0.5315985130111525
In [35]:
x = np.arange(0, 20)
data = []
for i in range(20):
  y_pred = model.predict(X_test_vec[y_test == i])
  data.append(accuracy_score(y_test[y_test == i], y_pred))
  
data_tr = []
for i in range(20):
  y_pred = model.predict(X_train_vec[y_train == i])
  data_tr.append(accuracy_score(y_train[y_train == i], y_pred))
  
plt.figure(figsize = [16, 8])
plt.plot(x, data_tr)
plt.plot(x, data)
plt.xticks(x)
plt.ylabel('Точность классификации')
plt.xlabel('Категория')
plt.legend(['Обучающая выборка', 'Тестовая выборка'], loc = 'right')
plt.show()

Ваш ответ тут: Значение метрики на тестовой выборке меньше, чем кросс-валидации. Посмотрев на график можно понять - модель переобучилась, что естественно, при выборе всего одного соседа мы просто запоминаем всю обучающую выборку и реагируем на все выбросы.

Бонус [0,5 балла]

Вставьте мем, описывающий ваше состоянии после решения данного ноутбука

Ваша картинка тут

Screenshot 2025-10-17 at 20.45.53.png