Разобраться с нуля
Модели, метрики и подбор
Представление текста и классификатор это два разных шага. Сравниваем NB и SVC на одних и тех же статьях.
- Классификатор
- Программа, выбирающая метку по числам.
- MultinomialNB
- Наивный Байес: оценивает класс по частотам слов.
- LinearSVC
- Ищет линейные границы между классами.
- Гиперпараметр
- Настройка до обучения: размер словаря, n-граммы.
- GridSearchCV
- Перебор настроек с проверкой на нескольких частях train.
- Pipeline
- Цепочка векторизатор → модель, обучаемая внутри каждого CV-фолда.
- Macro-F1
- Средний F1 четырёх классов, каждый имеет одинаковый вес.
Байес без страшных формул
Если слова gene и cell гораздо чаще встречались в биологии, новая статья с ними получит высокую оценку Life Sciences. «Наивный» значит, что слова условно считаются независимыми при фиксированном классе. В реальности они связаны, но метод всё равно полезен.
SVC
Представь статьи точками. SVC проводит границы с зазором между классами. При 15000 словах это 15000 координат, а не плоский рисунок. Для четырёх классов sklearn строит отдельные линейные решения.
Как читать classification_report
Precision: из названных биологией сколько правда биология? Recall: из всех биологических статей сколько нашли? F1 уравновешивает precision и recall. Support: сколько настоящих статей класса на тесте.
recall = TP/(TP+FN)
F1 = 2PR/(P+R)
Например: 80 из 100 найденных статей верны, а настоящих 120. Precision=0.8, recall=0.6667, F1≈0.7273. Это учебный расчёт, не метрика нашего прогона.
Confusion matrix
Строка: настоящий домен. Столбец: предсказанный. Диагональ это правильные ответы. Большое число вне диагонали показывает, какие области путаются.
Правильный GridSearch
2 размера словаря × 2 варианта n-грамм × 2 min_df = 8 настроек на пару векторизатор/модель. Три CV-фолда, всего 24 обучения на пару. Test не выбирает настройки.
Где Pipeline учит словарь при CV?
Иначе словарь увидел бы отложенный CV-фолд и оценка была бы слишком оптимистичной.
Код и построчный разбор
Код исходной ячейки 12: from sklearn.naive_bayes import MultinomialNB
from sklearn.naive_bayes import MultinomialNB
from sklearn.svm import LinearSVC
clf1 = MultinomialNB()
# Прямой решатель и больше итераций: на BoW стандартного лимита не хватало.
clf2 = LinearSVC(random_state=42, dual=False, max_iter=5000)1: from sklearn.naive_bayes import MultinomialNB
Подключает готовые инструменты; import сам по себе модель не обучает.2: from sklearn.svm import LinearSVC
Подключает готовые инструменты; import сам по себе модель не обучает.4: clf1 = MultinomialNB()
Вычисляет правую часть и сохраняет результат в clf1.5: # Прямой решатель и больше итераций: на BoW стандартного лимита не хватало.
Комментарий объясняет причину следующего действия.6: clf2 = LinearSVC(random_state=42, dual=False, max_iter=5000)
Вычисляет правую часть и сохраняет результат в clf2.
Код исходной ячейки 13: from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.model_selection import train_test_split, GridSearchCV
from sklearn.metrics import classification_report, confusion_matrix, f1_score, accuracy_score
from sklearn.pipeline import Pipeline
from sklearn.base import clone
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
train_text, test_text, y_train, y_test = train_test_split(
df['title_abstract_preprocessed'], df['domain'], test_size=0.2, random_state=42, stratify=df['domain'])
print('Train:', len(train_text), '| test:', len(test_text))
labels = sorted(df['domain'].unique())
results = []
def evaluate(name, vectorizer, report=True):
start = perf_counter()
X_train = vectorizer.fit_transform(train_text)
X_test = vectorizer.transform(test_text)
vector_time = perf_counter() - start
for title, prototype in [('NB', clf1), ('SVC', clf2)]:
model = clone(prototype)
start = perf_counter()
model.fit(X_train, y_train)
fit_time = perf_counter() - start
start = perf_counter()
pred = model.predict(X_test)
pred_time = perf_counter() - start
results.append({'representation': name, 'model': title, 'macro_f1': f1_score(y_test, pred, average='macro'),
'accuracy': accuracy_score(y_test, pred), 'vector_s': vector_time, 'fit_s': fit_time, 'predict_s': pred_time})
if report:
print(name, title)
print(classification_report(y_test, pred, labels=labels, zero_division=0, digits=3))
plt.figure(figsize=(7, 5))
sns.heatmap(confusion_matrix(y_test, pred, labels=labels), annot=True, fmt='d',
xticklabels=labels, yticklabels=labels, cmap='YlOrBr')
plt.xlabel('Предсказанный домен'); plt.ylabel('Настоящий домен')
plt.title(name + ' / ' + title)
plt.xticks(rotation=25, ha='right'); plt.yticks(rotation=0)
plt.tight_layout(); plt.show()
return vectorizer1: from sklearn.model_selection import train_test_split, GridSearchCV
Подключает готовые инструменты; import сам по себе модель не обучает.2: from sklearn.metrics import classification_report, confusion_matrix, f1_score, accuracy_score
Подключает готовые инструменты; import сам по себе модель не обучает.3: from sklearn.pipeline import Pipeline
Подключает готовые инструменты; import сам по себе модель не обучает.4: from sklearn.base import clone
Подключает готовые инструменты; import сам по себе модель не обучает.5: from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
Подключает готовые инструменты; import сам по себе модель не обучает.7: train_text, test_text, y_train, y_test = train_test_split(
Делит строки на обучение и тест, сохраняя доли классов.8: df['title_abstract_preprocessed'], df['domain'], test_size=0.2, random_state=42, stratify=df['domain'])
Вычисляет правую часть и сохраняет результат в df['title_abstract_preprocessed'], df['domain'], test_size.9: print('Train:', len(train_text), '| test:', len(test_text))
Печатает число или пояснение, чтобы проверить результат выполнения.10: labels = sorted(df['domain'].unique())
Сортирует значения, чтобы порядок слов и столбцов был воспроизводимым.11: results = []
Вычисляет правую часть и сохраняет результат в results.13: def evaluate(name, vectorizer, report=True):
Объявляет функцию: код внутри выполняется при её вызове.14: start = perf_counter()
Замеряет время, чтобы отдельно оценить затраты на этот этап.15: X_train = vectorizer.fit_transform(train_text)
Выучивает словарь и статистику по обучающим текстам и сразу строит их матрицу.16: X_test = vectorizer.transform(test_text)
Применяет уже выученную статистику, не добавляя слов из test.17: vector_time = perf_counter() - start
Замеряет время, чтобы отдельно оценить затраты на этот этап.18: for title, prototype in [('NB', clf1), ('SVC', clf2)]:
Повторяет вложенный блок для каждого элемента указанной последовательности.19: model = clone(prototype)
Вычисляет правую часть и сохраняет результат в model.20: start = perf_counter()
Замеряет время, чтобы отдельно оценить затраты на этот этап.21: model.fit(X_train, y_train)
Обучает объект по переданным данным; test здесь использовать нельзя.22: fit_time = perf_counter() - start
Замеряет время, чтобы отдельно оценить затраты на этот этап.23: start = perf_counter()
Замеряет время, чтобы отдельно оценить затраты на этот этап.24: pred = model.predict(X_test)
Получает предсказанные домены из обученного классификатора.25: pred_time = perf_counter() - start
Замеряет время, чтобы отдельно оценить затраты на этот этап.26: results.append({'representation': name, 'model': title, 'macro_f1': f1_score(y_test, pred, average='macro'),
Добавляет один результат в список; порядок сохраняется.27: 'accuracy': accuracy_score(y_test, pred), 'vector_s': vector_time, 'fit_s': fit_time, 'predict_s': pred_time})
Продолжает выражение предыдущей строки: здесь заданы данные или аргументы вызова.28: if report:
Выполняет вложенный блок только при истинном условии.29: print(name, title)
Печатает число или пояснение, чтобы проверить результат выполнения.30: print(classification_report(y_test, pred, labels=labels, zero_division=0, digits=3))
Выводит precision, recall, F1 и support отдельно по каждому классу.31: plt.figure(figsize=(7, 5))
Настраивает график, подписи или выводит его на экран.32: sns.heatmap(confusion_matrix(y_test, pred, labels=labels), annot=True, fmt='d',
Считает таблицу настоящих и предсказанных меток.33: xticklabels=labels, yticklabels=labels, cmap='YlOrBr')
Вычисляет правую часть и сохраняет результат в xticklabels.34: plt.xlabel('Предсказанный домен'); plt.ylabel('Настоящий домен')
Настраивает график, подписи или выводит его на экран.35: plt.title(name + ' / ' + title)
Настраивает график, подписи или выводит его на экран.36: plt.xticks(rotation=25, ha='right'); plt.yticks(rotation=0)
Настраивает график, подписи или выводит его на экран.37: plt.tight_layout(); plt.show()
Настраивает график, подписи или выводит его на экран.38: return vectorizer
Возвращает результат функции вызывающему коду.
Код исходной ячейки 23: # Сравнение одинаковых словарей: проверяем каждое значение, а не только метрику.
# Сравнение одинаковых словарей: проверяем каждое значение, а не только метрику.
check_own = BagOfWords((1, 2), 15000).fit(train_text)
check_sk = CountVectorizer(vocabulary=check_own.vocabulary, ngram_range=(1, 2))
assert (check_own.transform(train_text) != check_sk.transform(train_text)).nnz == 0
assert (check_own.transform(test_text) != check_sk.transform(test_text)).nnz == 0
print('Матрицы BoW совпадают со sklearn при одном словаре')
evaluate('sklearn BoW fixed vocabulary', check_sk)
searches = {}
for kind, vec in [('BoW', CountVectorizer()), ('TFIDF', TfidfVectorizer())]:
for title, prototype in [('NB', clf1), ('SVC', clf2)]:
pipe = Pipeline([('vec', vec), ('clf', clone(prototype))])
grid = {'vec__max_features': [5000, 15000], 'vec__ngram_range': [(1, 1), (1, 2)], 'vec__min_df': [1, 3]}
start = perf_counter()
search = GridSearchCV(pipe, grid, scoring='f1_macro', cv=3, n_jobs=1)
search.fit(train_text, y_train)
searches[(kind, title)] = search
print(kind, title, 'CV macro-F1:', round(search.best_score_, 4), search.best_params_, 'сек:', round(perf_counter()-start, 1))
# Здесь только train-CV; test для подбора не используется.1: # Сравнение одинаковых словарей: проверяем каждое значение, а не только метрику.
Комментарий объясняет причину следующего действия.2: check_own = BagOfWords((1, 2), 15000).fit(train_text)
Обучает объект по переданным данным; test здесь использовать нельзя.3: check_sk = CountVectorizer(vocabulary=check_own.vocabulary, ngram_range=(1, 2))
Вычисляет правую часть и сохраняет результат в check_sk.4: assert (check_own.transform(train_text) != check_sk.transform(train_text)).nnz == 0
Проверяет ожидаемое свойство; если оно нарушено, останавливает запуск.5: assert (check_own.transform(test_text) != check_sk.transform(test_text)).nnz == 0
Проверяет ожидаемое свойство; если оно нарушено, останавливает запуск.6: print('Матрицы BoW совпадают со sklearn при одном словаре')
Печатает число или пояснение, чтобы проверить результат выполнения.7: evaluate('sklearn BoW fixed vocabulary', check_sk)
Продолжает выражение предыдущей строки: здесь заданы данные или аргументы вызова.9: searches = {}
Вычисляет правую часть и сохраняет результат в searches.10: for kind, vec in [('BoW', CountVectorizer()), ('TFIDF', TfidfVectorizer())]:
Повторяет вложенный блок для каждого элемента указанной последовательности.11: for title, prototype in [('NB', clf1), ('SVC', clf2)]:
Повторяет вложенный блок для каждого элемента указанной последовательности.12: pipe = Pipeline([('vec', vec), ('clf', clone(prototype))])
Вычисляет правую часть и сохраняет результат в pipe.13: grid = {'vec__max_features': [5000, 15000], 'vec__ngram_range': [(1, 1), (1, 2)], 'vec__min_df': [1, 3]}
Вычисляет правую часть и сохраняет результат в grid.14: start = perf_counter()
Замеряет время, чтобы отдельно оценить затраты на этот этап.15: search = GridSearchCV(pipe, grid, scoring='f1_macro', cv=3, n_jobs=1)
Вычисляет правую часть и сохраняет результат в search.16: search.fit(train_text, y_train)
Обучает объект по переданным данным; test здесь использовать нельзя.17: searches[(kind, title)] = search
Вычисляет правую часть и сохраняет результат в searches[(kind, title)].18: print(kind, title, 'CV macro-F1:', round(search.best_score_, 4), search.best_params_, 'сек:', round(perf_counter()-start, 1))
Замеряет время, чтобы отдельно оценить затраты на этот этап.19: # Здесь только train-CV; test для подбора не используется.
Комментарий объясняет причину следующего действия.
Результаты нашего прогона
| Векторы | Модель | CV macro-F1 | Test macro-F1 | Настройки |
|---|---|---|---|---|
| BoW | NB | 0.8388 | 0.8439 | 15000 признаков, [1, 1], min_df=1 |
| BoW | SVC | 0.8245 | 0.8300 | 15000 признаков, [1, 2], min_df=1 |
| TFIDF | NB | 0.8411 | 0.8368 | 15000 признаков, [1, 2], min_df=3 |
| TFIDF | SVC | 0.8664 | 0.8680 | 15000 признаков, [1, 1], min_df=1 |
| GloVe mean | SVC | Не подбирался | 0.8186 | 200 координат |
Настройки выбирались по CV. Колонка test это окончательная оценка; 0.9 означает высокий F1, но F1 не равен доле правильных ответов.
Вопросы защиты
Зачем две модели?
Чтобы сравнить влияние представления текста: полезность TF-IDF зависит от классификатора.
Почему macro-F1?
Каждый домен равноправен. Сейчас классы сбалансированы, но macro-F1 явно проверяет качество каждого.
Что значит min_df=3?
Исключить слово, встретившееся менее чем в трёх обучающих документах.
Какая модель лучше?
Смотри реальные результаты ниже и ответ в ноутбуке, не угадывай заранее.