Практика · Наскрізний проєкт¶
Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md
Один проєкт від початку до кінця: детектор шахрайських оголошень на тій самій дошці про вживані телефони, що й у темі 08.
Порядок кроків тут важливіший за код:
- постановка — що передбачаємо й скільки коштує кожна помилка;
- поділ — тест відкладаємо першою дією, до будь-якого погляду на дані;
- розвідка — тільки на навчальній частині;
- базова лінія — дурний прогноз і його число;
- перша чесна модель — конвеєр і крос-валідація;
- ітерації — ознака, інша модель, налаштування; одна спроба не спрацює;
- помилки очима — дивимось на конкретні оголошення;
- фінальний тест — рівно один раз;
- висновок — чи можна це запускати.
Числа тут ті самі, що в лекції: генератор випадкових чисел зафіксовано зерном 42.
import warnings
import numpy as np
import pandas as pd
from sklearn.dummy import DummyClassifier
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import (train_test_split, StratifiedKFold,
cross_validate, cross_val_predict, GridSearchCV)
from sklearn.metrics import (f1_score, precision_score, recall_score,
accuracy_score, confusion_matrix)
# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
rng = np.random.default_rng(42)
# базова лінія жодного разу не скаже «шахрайське», тому precision для неї
# невизначений (нуль ділиться на нуль) і sklearn про це попереджає. Це саме те,
# що ми хочемо показати, тож прибираємо попередження, а не ховаємо результат.
warnings.filterwarnings("ignore", message="Precision is ill-defined")
pd.set_option("display.width", 130)
pd.set_option("display.max_columns", 14)
print("numpy", np.__version__, "· pandas", pd.__version__)
numpy 2.5.1 · pandas 2.3.3
0 · Постановка. Що ми взагалі робимо¶
Перед першим рядком коду треба відповісти на чотири питання. Це найдешевший крок проєкту і той, який найчастіше пропускають.
- Що передбачаємо. Чи є оголошення шахрайським — у момент публікації, до того, як хтось на нього відгукнувся. Отже, ознаками можуть бути тільки поля, відомі в цю мить.
- Кому це потрібно. Модератору дошки. Він не читає всі оголошення підряд — він читає ті, які модель підняла нагору.
- Скільки коштує помилка. Пропустили шахрая — покупець втратив передоплату. Підняли тривогу на чесному продавцеві — образили людину й забрали час модератора. Ціни різні, але обидві не нульові.
- Яка з цього метрика. Точність (accuracy) не годиться: шахрайських оголошень близько 16 %, і константа «всі чесні» дасть 84 % (тема 05). Нам потрібні одразу дві величини — скільки шахраїв упіймали (recall) і яка частка тривог виявилась справжньою (precision). Для одного числа, за яким порівнювати кроки, беремо F1.
Метрику обрано до навчання. Якщо обирати після, вибереш ту, яка красивіше виглядає.
кількість = 1200
моделі = ["Alfa A5", "Alfa A7", "Beta 12", "Beta 12 Pro", "Gamma X", "Gamma X Ultra"]
ціна_нового = {"Alfa A5": 5200, "Alfa A7": 7400, "Beta 12": 12000,
"Beta 12 Pro": 17500, "Gamma X": 24000, "Gamma X Ultra": 34000}
частки_моделей = [0.24, 0.22, 0.18, 0.16, 0.12, 0.08]
модель = rng.choice(моделі, size=кількість, p=частки_моделей)
рік = rng.integers(2017, 2025, size=кількість)
стан = rng.choice(["нове", "дуже добре", "добре", "задовільне"],
size=кількість, p=[0.08, 0.32, 0.42, 0.18])
памʼять = rng.choice([64, 128, 256, 512], size=кількість, p=[0.30, 0.38, 0.24, 0.08])
вік_акаунта = np.round(rng.exponential(420, size=кількість) + 3).astype(int)
базова = np.array([ціна_нового[m] for m in модель])
знос = 0.82 ** (2024 - рік)
коефіцієнт_стану = np.array(
[{"нове": 1.0, "дуже добре": 0.88, "добре": 0.75, "задовільне": 0.58}[s] for s in стан])
коефіцієнт_памʼяті = np.array(
[{64: 0.85, 128: 1.0, 256: 1.15, 512: 1.32}[m] for m in памʼять])
типова_ціна_за_паспортом = базова * знос * коефіцієнт_стану * коефіцієнт_памʼяті
ціна = типова_ціна_за_паспортом * rng.lognormal(0, 0.13, size=кількість)
print("перші пʼять цін:", ціна[:5].round(0))
перші пʼять цін: [ 3085. 2810. 10822. 8346. 1852.]
# шахрай тим імовірніший, чим молодший акаунт; ціну він або занижує (приманка),
# або завищує під велику передоплату
шанс_шахрайства = 0.10 + 0.30 * np.exp(-вік_акаунта / 120)
шахрайське = rng.random(кількість) < шанс_шахрайства
ставить_дешево = rng.random(кількість) < 0.74
дешева_приманка = шахрайське & ставить_дешево
дорога_приманка = шахрайське & ~ставить_дешево
ціна[дешева_приманка] = (типова_ціна_за_паспортом[дешева_приманка]
* rng.uniform(0.20, 0.45, дешева_приманка.sum()))
ціна[дорога_приманка] = (типова_ціна_за_паспортом[дорога_приманка]
* rng.uniform(2.6, 3.8, дорога_приманка.sum()))
ціна = np.round(ціна, -1)
скарг = np.where(шахрайське, 1 + rng.poisson(3.0, кількість), rng.poisson(0.03, кількість))
дошка = pd.DataFrame({
"модель": модель, "рік": рік, "стан": стан, "памʼять_гб": памʼять,
"вік_акаунта": вік_акаунта, "скарг": скарг, "ціна": ціна,
"шахрайське": шахрайське.astype(int),
})
print("шахрайських оголошень:", int(дошка["шахрайське"].sum()), "з", кількість)
шахрайських оголошень: 196 з 1200
# ті самі шість неприємностей із теми 08 — колекційні, одруки, памʼять текстом,
# пропуски в ціні та стані, дублікати
колекційні = дошка.index[дошка["модель"] == "Gamma X"][:4]
дошка.loc[колекційні, ["рік", "стан", "памʼять_гб"]] = [2017, "нове", 512]
дошка.loc[колекційні, "ціна"] = [82000.0, 88000.0, 91000.0, 95000.0]
дошка.loc[колекційні, ["шахрайське", "скарг"]] = 0
одруки = дошка.index[(дошка["ціна"] > 7000) & (дошка["ціна"] < 9600)
& (дошка["шахрайське"] == 0)][:2]
дошка.loc[одруки, "ціна"] = дошка.loc[одруки, "ціна"] * 10
памʼять_текстом = дошка["памʼять_гб"].astype(str)
із_одиницями = rng.random(len(дошка)) < 0.18
памʼять_текстом[із_одиницями] = памʼять_текстом[із_одиницями] + " ГБ"
дошка["памʼять_гб"] = памʼять_текстом
ймовірність_пропуску = np.where(дошка["шахрайське"] == 1, 0.25, 0.03)
дошка.loc[rng.random(len(дошка)) < ймовірність_пропуску, "ціна"] = np.nan
дошка.loc[rng.random(len(дошка)) < 0.04, "стан"] = np.nan
повтори = rng.choice(дошка.index, size=12, replace=False)
дошка = pd.concat([дошка, дошка.loc[повтори]], ignore_index=True)
print("таблиця як у темі 08:", дошка.shape)
assert дошка.shape == (1212, 8), "форма розійшлась із темою 22"
print("✅ форма збігається")
таблиця як у темі 08: (1212, 8) ✅ форма збігається
Дві безумовні чистки¶
Дві речі можна виправити ще до поділу, бо вони не потребують жодної статистики з даних: суфікс «ГБ» у памʼяті (це просто інший запис того самого числа) і повні дублікати рядків. Дублікати прибирають саме тут: якщо той самий рядок потрапить і в навчальну частину, і в тестову, тест буде перевіряти модель на тому, що вона вже бачила.
А от заповнення пропусків, межі викидів і будь-які медіани — після поділу. Це вже статистика, і рахувати її на всій таблиці означає підглянути в тест (тема 09).
дошка["памʼять_гб"] = pd.to_numeric(дошка["памʼять_гб"].str.replace(" ГБ", "", regex=False))
дошка = дошка.drop_duplicates().reset_index(drop=True)
print("після чистки:", дошка.shape)
print("шахрайських:", int(дошка["шахрайське"].sum()),
f"({дошка['шахрайське'].mean() * 100:.1f} %)")
після чистки: (1200, 8) шахрайських: 195 (16.2 %)
2 · Поділ — перша дія, а не остання¶
Зараз ми ще нічого не знаємо про ці дані. Саме тому час відкладати тест: усе, що ти побачиш далі, вже вплине на твої рішення, а рішення, прийняті з оглядкою на тест, роблять його оцінку оптимістичною (тема 03).
stratify тримає однакову частку шахрайських в обох частинах — на рідкому класі це
обовʼязково, інакше частка стрибне на кілька відсотків просто від жеребкування.
таргет = дошка["шахрайське"]
усі_ознаки = дошка.drop(columns=["шахрайське"])
навч_X, тест_X, навч_y, тест_y = train_test_split(
усі_ознаки, таргет, test_size=0.25, random_state=42, stratify=таргет)
print("навчальна частина:", навч_X.shape, "· шахрайських:", int(навч_y.sum()),
f"({навч_y.mean() * 100:.1f} %)")
print("тестова частина :", тест_X.shape, "· шахрайських:", int(тест_y.sum()),
f"({тест_y.mean() * 100:.1f} %)")
print()
print("З цієї миті й до останнього розділу тестова частина не використовується ніде.")
навчальна частина: (900, 7) · шахрайських: 146 (16.2 %) тестова частина : (300, 7) · шахрайських: 49 (16.3 %) З цієї миті й до останнього розділу тестова частина не використовується ніде.
print("пропуски в навчальній частині:")
print(навч_X.isna().sum()[lambda колонка: колонка > 0])
print()
# пропуск у ціні — не випадковість: подивимось, кого в цих рядках більше
без_ціни = навч_X["ціна"].isna()
print(f"рядків БЕЗ ціни: {int(без_ціни.sum()):3d} · шахрайських серед них: "
f"{навч_y[без_ціни].mean() * 100:.1f} %")
print(f"рядків З ціною : {int((~без_ціни).sum()):3d} · шахрайських серед них: "
f"{навч_y[~без_ціни].mean() * 100:.1f} %")
пропуски в навчальній частині: стан 30 ціна 72 dtype: int64 рядків БЕЗ ціни: 72 · шахрайських серед них: 54.2 % рядків З ціною : 828 · шахрайських серед них: 12.9 %
Перше рішення прийнято: пропуск у ціні сам по собі є сигналом, тому рядки з пропуском ми не викидаємо, а додамо окремий прапорець «ціна відома».
Тепер друге питання — чи немає серед колонок такої, якої в момент публікації ще не існує.
print("кореляція кожної числової колонки з таргетом:")
print(навч_X[["ціна", "рік", "памʼять_гб", "вік_акаунта", "скарг"]]
.corrwith(навч_y).round(3))
print()
print("максимальна ціна:", навч_X["ціна"].max(), "грн — це колекційні телефони й одруки")
кореляція кожної числової колонки з таргетом: ціна -0.054 рік 0.035 памʼять_гб 0.113 вік_акаунта -0.099 скарг 0.891 dtype: float64 максимальна ціна: 95000.0 грн — це колекційні телефони й одруки
Колонка скарг дає кореляцію 0.891, тоді як усі інші — не більше 0.12. Таке число
має лякати, а не радувати. Скарги зʼявляються після публікації, коли шахрая вже
викрили: у момент, коли модель має видати відповідь, там завжди нуль. Це
витік (тема 08).
Подивимось, скільки коштує спокуса. Навчимо модель разом зі скарг і без неї.
def конвеєр(алгоритм, числові, категорійні):
'''Один обʼєкт, який робить усе: закриває пропуски, масштабує,
кодує категорії й навчає модель. Усе, що рахується з даних, рахується
всередині — тому крос-валідація не підгляне в перевірочну частину.'''
числовий_шлях = Pipeline([
("пропуски", SimpleImputer(strategy="median")),
("масштаб", StandardScaler()),
])
категорійний_шлях = Pipeline([
("пропуски", SimpleImputer(strategy="most_frequent")),
("кодування", OneHotEncoder(handle_unknown="ignore")),
])
підготовка = ColumnTransformer([
("числові", числовий_шлях, числові),
("категорії", категорійний_шлях, категорійні),
])
return Pipeline([("підготовка", підготовка), ("модель", алгоритм)])
поділ = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
def оцінити(модель_конвеєр, таблиця, мітки, назва):
'''Пʼятиразова крос-валідація одразу за чотирма метриками.'''
оцінки = cross_validate(модель_конвеєр, таблиця, мітки, cv=поділ,
scoring=["f1", "precision", "recall", "accuracy"])
рядок = {"крок": назва,
"F1": round(оцінки["test_f1"].mean(), 3),
"precision": round(оцінки["test_precision"].mean(), 3),
"recall": round(оцінки["test_recall"].mean(), 3),
"accuracy": round(оцінки["test_accuracy"].mean(), 3)}
print(f"{назва:34s} F1={рядок['F1']:.3f} precision={рядок['precision']:.3f}"
f" recall={рядок['recall']:.3f} accuracy={рядок['accuracy']:.3f}")
return рядок
числові_вихідні = ["ціна", "рік", "памʼять_гб", "вік_акаунта"]
категорійні = ["модель", "стан"]
_ = оцінити(конвеєр(LogisticRegression(max_iter=1000),
числові_вихідні + ["скарг"], категорійні),
навч_X[числові_вихідні + ["скарг"] + категорійні], навч_y,
"з колонкою «скарг»")
print()
print("F1 = 0.974 — і жодного шахрая ця модель не спіймає в бою,")
print("бо в момент публікації в колонці «скарг» стоїть нуль у всіх.")
з колонкою «скарг» F1=0.974 precision=1.000 recall=0.952 accuracy=0.992 F1 = 0.974 — і жодного шахрая ця модель не спіймає в бою, бо в момент публікації в колонці «скарг» стоїть нуль у всіх.
# колонку «скарг» відкладаємо назавжди — ні в навчанні, ні в тесті її не буде
навч_X = навч_X.drop(columns=["скарг"])
тест_X = тест_X.drop(columns=["скарг"])
print("колонки, з якими працюємо далі:", list(навч_X.columns))
колонки, з якими працюємо далі: ['модель', 'рік', 'стан', 'памʼять_гб', 'вік_акаунта', 'ціна']
4 · Базова лінія¶
Тепер найдешевший крок проєкту. DummyClassifier зі стратегією most_frequent завжди
відповідає «чесне» й не дивиться на ознаки взагалі. Його число — нуль відліку, без якого
будь-яка подальша «точність 0.94» нічого не означає.
базова_лінія = DummyClassifier(strategy="most_frequent")
крок_0 = оцінити(базова_лінія, навч_X[числові_вихідні + категорійні], навч_y,
"0. базова лінія")
print()
print("accuracy 0.838 при F1 = 0.000: модель не помиляється у 84 % випадків")
print("і не знаходить жодного шахрая. Ось чому accuracy тут не метрика.")
0. базова лінія F1=0.000 precision=0.000 recall=0.000 accuracy=0.838 accuracy 0.838 при F1 = 0.000: модель не помиляється у 84 % випадків і не знаходить жодного шахрая. Ось чому accuracy тут не метрика.
крок_1 = оцінити(конвеєр(LogisticRegression(max_iter=1000), числові_вихідні, категорійні),
навч_X[числові_вихідні + категорійні], навч_y,
"1. логістична, вихідні стовпці")
print()
print("F1 = 0.013 проти 0.000 у базової лінії — це не результат, це його відсутність.")
1. логістична, вихідні стовпці F1=0.013 precision=0.200 recall=0.007 accuracy=0.839 F1 = 0.013 проти 0.000 у базової лінії — це не результат, це його відсутність.
Тут працює правило з теми 28: якщо проста модель не відірвалась від базової лінії, справа майже напевно в даних, а не в алгоритмі. Складніший алгоритм не знайде того, чого в стовпцях немає.
І ми знаємо, чого саме немає. Число «1 310 грн» не означає нічого, поки не сказано, скільки коштує такий самий телефон (тема 10). Будуємо цю ознаку — але карту типових цін рахуємо тільки на навчальній частині.
# медіанна ціна всередині групи «модель + рік» — карта будується лише на навч_X
карта_типових_цін = навч_X.groupby(["модель", "рік"])["ціна"].median()
загальна_медіана = навч_X["ціна"].median()
def додати_ознаки(таблиця):
'''Дві ознаки: наскільки ціна відхиляється від типової для цієї моделі й року,
і чи вказана ціна взагалі.'''
нова = таблиця.copy()
ключі = pd.MultiIndex.from_arrays([нова["модель"], нова["рік"]])
# групам, яких у навчальній частині не було, підставляємо загальну медіану
нова["типова_ціна"] = ключі.map(карта_типових_цін).fillna(загальна_медіана)
# модуль логарифма робить «утричі дешевше» й «утричі дорожче» однаково великими
нова["відхилення"] = np.abs(np.log(нова["ціна"] / нова["типова_ціна"]))
нова["ціна_відома"] = нова["ціна"].notna().astype(int)
return нова
навч_F = додати_ознаки(навч_X)
тест_F = додати_ознаки(тест_X)
числові_з_ознакою = числові_вихідні + ["відхилення", "ціна_відома"]
крок_2 = оцінити(конвеєр(LogisticRegression(max_iter=1000), числові_з_ознакою, категорійні),
навч_F[числові_з_ознакою + категорійні], навч_y,
"2. + відхилення від типової")
print()
print("F1: 0.013 → 0.835. Одна ознака зробила більше, ніж зробить будь-яка заміна моделі.")
2. + відхилення від типової F1=0.835 precision=0.859 recall=0.814 accuracy=0.948 F1: 0.013 → 0.835. Одна ознака зробила більше, ніж зробить будь-яка заміна моделі.
6 · Ітерація, яка не спрацювала¶
Наступний хід очевидний: узяти сильнішу модель. Випадковий ліс бачить нелінійності й взаємодії, яких логістична регресія не бачить, працює зі значеннями за замовчуванням і не боїться масштабу. Мав би виграти.
крок_3 = оцінити(конвеєр(RandomForestClassifier(n_estimators=100, random_state=42),
числові_з_ознакою, категорійні),
навч_F[числові_з_ознакою + категорійні], навч_y,
"3. випадковий ліс")
print()
print("0.804 проти 0.835 — гірше. Ліс залишається в блокноті як спроба, а не в моделі.")
print("Розділювальна межа тут майже пряма: 'відхилення більше за поріг → шахрай'.")
print("Ламана межа лісу таку пряму лише погано наслідує.")
3. випадковий ліс F1=0.804 precision=0.837 recall=0.774 accuracy=0.939 0.804 проти 0.835 — гірше. Ліс залишається в блокноті як спроба, а не в моделі. Розділювальна межа тут майже пряма: 'відхилення більше за поріг → шахрай'. Ламана межа лісу таку пряму лише погано наслідує.
Це нормальний крок проєкту, а не невдача автора. Приблизно половина спроб не дає приросту — і саме тому кожну міряють, а не проголошують. Заводь окремий стовпчик «що пробував і що вийшло»: без нього через тиждень спробуєш те саме вдруге.
7 · Налаштування¶
Логістична регресія має рівно дві ручки, які тут щось означають: сила регуляризації
C (тема 23) і class_weight, який змушує модель
дорожче платити за пропущеного шахрая. Перебираємо шість поєднань
(тема 24).
сітка = GridSearchCV(
конвеєр(LogisticRegression(max_iter=1000), числові_з_ознакою, категорійні),
param_grid={"модель__C": [0.1, 1, 10],
"модель__class_weight": [None, "balanced"]},
scoring="f1", cv=поділ, n_jobs=-1)
сітка.fit(навч_F[числові_з_ознакою + категорійні], навч_y)
результати = pd.DataFrame(сітка.cv_results_)[
["param_модель__C", "param_модель__class_weight", "mean_test_score"]]
результати.columns = ["C", "class_weight", "F1 на крос-валідації"]
print(результати.round(3).to_string(index=False))
print()
print("найкраще поєднання:", сітка.best_params_)
print("F1 на крос-валідації:", round(сітка.best_score_, 3))
C class_weight F1 на крос-валідації
0.1 None 0.761
0.1 balanced 0.820
1.0 None 0.835
1.0 balanced 0.822
10.0 None 0.844
10.0 balanced 0.815
найкраще поєднання: {'модель__C': 10, 'модель__class_weight': None}
F1 на крос-валідації: 0.844
крок_4 = оцінити(конвеєр(LogisticRegression(max_iter=1000, C=10), числові_з_ознакою,
категорійні),
навч_F[числові_з_ознакою + категорійні], навч_y,
"4. налаштована логістична")
print()
print("0.835 → 0.844. Приріст є, але він майже в сто разів менший за той,")
print("що дала одна ознака. Так буває майже завжди.")
4. налаштована логістична F1=0.844 precision=0.854 recall=0.835 accuracy=0.950 0.835 → 0.844. Приріст є, але він майже в сто разів менший за той, що дала одна ознака. Так буває майже завжди.
8 · Помилки очима¶
Метрика каже, скільки помилок. Вона не каже, які. Беремо прогнози, зроблені
на крос-валідації (cross_val_predict віддає для кожного оголошення прогноз тієї
моделі, яка його не бачила), і дивимось на конкретні рядки.
прогноз_поза_навчанням = cross_val_predict(
конвеєр(LogisticRegression(max_iter=1000, C=10), числові_з_ознакою, категорійні),
навч_F[числові_з_ознакою + категорійні], навч_y, cv=поділ)
матриця = confusion_matrix(навч_y, прогноз_поза_навчанням)
print("матриця помилок на навчальній частині (крос-валідація):")
print(pd.DataFrame(матриця,
index=["насправді чесне", "насправді шахрайське"],
columns=["сказали чесне", "сказали шахрайське"]))
матриця помилок на навчальній частині (крос-валідація):
сказали чесне сказали шахрайське
насправді чесне 733 21
насправді шахрайське 24 122
Перевірка: F1 руками проти бібліотечного¶
F1 — це просто число, яке дістається з чотирьох клітинок цієї матриці. Переконаймось,
що всередині f1_score немає магії.
# розкладаємо матрицю: чесні названі чесними, хибні тривоги, пропущені шахраї, спіймані
чесні_правильно, хибні_тривоги, пропущені, спіймані = матриця.ravel()
наша_precision = спіймані / (спіймані + хибні_тривоги)
наш_recall = спіймані / (спіймані + пропущені)
наш_F1 = 2 * наша_precision * наш_recall / (наша_precision + наш_recall)
print("наш precision :", round(наша_precision, 6))
print("наш recall :", round(наш_recall, 6))
print("наш F1 :", round(наш_F1, 6))
print("f1_score :", round(f1_score(навч_y, прогноз_поза_навчанням), 6))
assert np.allclose(наш_F1, f1_score(навч_y, прогноз_поза_навчанням)), "розрахунок розійшовся!"
print("✅ збігається")
наш precision : 0.853147 наш recall : 0.835616 наш F1 : 0.844291 f1_score : 0.844291 ✅ збігається
розбір = навч_F.copy()
розбір["правда"] = навч_y.values
розбір["прогноз"] = прогноз_поза_навчанням
розбір["група"] = np.select(
[(розбір["правда"] == 1) & (розбір["прогноз"] == 1),
(розбір["правда"] == 0) & (розбір["прогноз"] == 0),
(розбір["правда"] == 1) & (розбір["прогноз"] == 0)],
["спіймані", "чесні правильно", "пропущені шахраї"],
default="хибні тривоги")
зведення = розбір.groupby("група").agg(
оголошень=("правда", "size"),
без_ціни=("ціна_відома", lambda стовпець: int((стовпець == 0).sum())),
мед_відхилення=("відхилення", "median"),
мед_вік_акаунта=("вік_акаунта", "median"))
print(зведення.round(2))
оголошень без_ціни мед_відхилення мед_вік_акаунта група пропущені шахраї 24 12 0.62 143.0 спіймані 122 27 1.11 138.0 хибні тривоги 21 16 2.48 315.0 чесні правильно 733 17 0.16 323.0
Дві групи помилок, і в них є спільне. Порахуємо його прямо.
помилка = розбір["група"].isin(["пропущені шахраї", "хибні тривоги"])
немає_ціни = розбір["ціна_відома"] == 0
print("усього помилок:", int(помилка.sum()))
print("з них на оголошеннях без ціни:", int((помилка & немає_ціни).sum()),
f"({(помилка & немає_ціни).sum() / помилка.sum() * 100:.0f} %)")
print()
print("а самих оголошень без ціни:", int(немає_ціни.sum()), "з", len(розбір),
f"({немає_ціни.mean() * 100:.1f} %)")
print()
print("частка помилок серед оголошень БЕЗ ціни:",
f"{(помилка & немає_ціни).sum() / немає_ціни.sum() * 100:.0f} %")
print("частка помилок серед оголошень З ціною :",
f"{(помилка & ~немає_ціни).sum() / (~немає_ціни).sum() * 100:.1f} %")
усього помилок: 45 з них на оголошеннях без ціни: 28 (62 %) а самих оголошень без ціни: 72 з 900 (8.0 %) частка помилок серед оголошень БЕЗ ціни: 39 % частка помилок серед оголошень З ціною : 2.1 %
Ось воно: 8 % даних дають 62 % помилок. Причина проста — головна ознака побудована з ціни, а в цих рядках ціни немає, і серед них шахрайських 54 %, тобто майже підкидання монети. Жодна нова ознака цього не виправить: сигналу в цих рядках просто немає. Це не задача для моделі, а рішення про продукт — до нього повернемось у розділі 11.
Тепер подивимось на другу групу — хибні тривоги, де ціна таки є.
хибні_з_ціною = розбір[(розбір["група"] == "хибні тривоги") & (розбір["ціна_відома"] == 1)]
print(хибні_з_ціною[["модель", "рік", "ціна", "типова_ціна", "відхилення", "вік_акаунта"]]
.sort_values("відхилення", ascending=False).round(2).to_string())
модель рік ціна типова_ціна відхилення вік_акаунта 13 Gamma X 2017 88000.0 5100.0 2.85 87 2 Gamma X 2017 82000.0 5100.0 2.78 44 11 Gamma X Ultra 2017 76000.0 6345.0 2.48 556 306 Alfa A7 2022 1790.0 4005.0 0.81 315 185 Beta 12 Pro 2021 4450.0 8030.0 0.59 339
Хибних тривог із відомою ціною всього пʼять, і три з них ми вже бачили. Два —
це Gamma X 2017 року за 82 і 88 тисяч, тобто колекційні телефони, які тема 08
знайшла ще на етапі розвідки. Третій — Gamma X Ultra за 76 000 грн, той самий одрук
«зайвий нуль» (справжня ціна 7 600). Модель робить рівно те, чого ми її навчили: бачить
ціну, яка в 12-17 разів вища за типову, і кричить «шахрай».
Правильна відповідь тут — не нова ознака. Ловити три рядки окремим правилом означає вчити модель на брудних даних. Правильно — виправити дані на вході: додати на дошку категорію «колекційне» й перевірку на порядок величини у формі подання. Це те, чого не видно з жодної метрики і що видно за пʼять хвилин, якщо подивитись на самі рядки.
9 · Поріг рішення¶
Модель віддає ймовірність, а рішення «підняти тривогу» ухвалює поріг. За замовчуванням це 0.5, але 0.5 — не закон природи, а значення з коробки. Порахуємо, у що перетворюється кожен поріг у наслідках задачі.
ймовірності_поза_навчанням = cross_val_predict(
конвеєр(LogisticRegression(max_iter=1000, C=10), числові_з_ознакою, категорійні),
навч_F[числові_з_ознакою + категорійні], навч_y, cv=поділ,
method="predict_proba")[:, 1]
рядки_порогів = []
for поріг in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]:
рішення = (ймовірності_поза_навчанням >= поріг).astype(int)
рядки_порогів.append({
"поріг": поріг,
"пропущено шахраїв": int(((навч_y.values == 1) & (рішення == 0)).sum()),
"хибних тривог": int(((навч_y.values == 0) & (рішення == 1)).sum()),
"precision": round(precision_score(навч_y, рішення, zero_division=0), 3),
"recall": round(recall_score(навч_y, рішення), 3),
"F1": round(f1_score(навч_y, рішення), 3)})
print(pd.DataFrame(рядки_порогів).to_string(index=False))
print()
print("146 шахрайських оголошень і 754 чесних у навчальній частині.")
поріг пропущено шахраїв хибних тривог precision recall F1 0.1 8 68 0.670 0.945 0.784 0.2 11 48 0.738 0.925 0.821 0.3 15 39 0.771 0.897 0.829 0.4 20 31 0.803 0.863 0.832 0.5 24 21 0.853 0.836 0.844 0.6 33 16 0.876 0.774 0.822 0.7 46 10 0.909 0.685 0.781 0.8 56 5 0.947 0.616 0.747 0.9 69 1 0.987 0.527 0.688 146 шахрайських оголошень і 754 чесних у навчальній частині.
Читаємо не метрики, а наслідки. Поріг 0.1: модератор пропускає 8 шахраїв і турбує 68 чесних продавців. Поріг 0.9: пропускає 69 і турбує одного. Вибір між цими рядками — не технічний, і робить його не той, хто пише код. Він робиться так: скільки коштує одна втрачена передоплата й скільки — одна образа чесного продавця.
Поріг ми обираємо тут, на крос-валідації, а не на тесті. Інакше тест перестане бути незалежним ще до того, як ми його відкриємо. Залишаємо 0.5: він дає найкращий F1 (0.844), а формулювати ціну помилки в гривнях у нас поки нема на чому.
10 · Фінальний замір — рівно один раз¶
Тепер усе вирішено: ознаки, модель, гіперпараметри, поріг. Навчаємо на всій навчальній частині й міряємо на тесті. Один раз. Що б це число не показало, воно вже нічого не змінює — інакше воно перестане бути чесним.
фінальна_модель = конвеєр(LogisticRegression(max_iter=1000, C=10),
числові_з_ознакою, категорійні)
фінальна_модель.fit(навч_F[числові_з_ознакою + категорійні], навч_y)
прогноз_на_тесті = фінальна_модель.predict(тест_F[числові_з_ознакою + категорійні])
print("=== ТЕСТ (перший і останній раз) ===")
print("F1 :", round(f1_score(тест_y, прогноз_на_тесті), 3))
print("precision :", round(precision_score(тест_y, прогноз_на_тесті), 3))
print("recall :", round(recall_score(тест_y, прогноз_на_тесті), 3))
print("accuracy :", round(accuracy_score(тест_y, прогноз_на_тесті), 3))
print()
print(pd.DataFrame(confusion_matrix(тест_y, прогноз_на_тесті),
index=["насправді чесне", "насправді шахрайське"],
columns=["сказали чесне", "сказали шахрайське"]))
=== ТЕСТ (перший і останній раз) ===
F1 : 0.796
precision : 0.841
recall : 0.755
accuracy : 0.937
сказали чесне сказали шахрайське
насправді чесне 244 7
насправді шахрайське 12 37
шлях = pd.DataFrame([крок_0, крок_1, крок_2, крок_3, крок_4,
{"крок": "5. ТЕСТ (один раз)",
"F1": round(f1_score(тест_y, прогноз_на_тесті), 3),
"precision": round(precision_score(тест_y, прогноз_на_тесті), 3),
"recall": round(recall_score(тест_y, прогноз_на_тесті), 3),
"accuracy": round(accuracy_score(тест_y, прогноз_на_тесті), 3)}])
print(шлях.to_string(index=False))
print()
print("крос-валідація обіцяла 0.844, тест дав 0.796 — на 0.048 менше.")
крок F1 precision recall accuracy
0. базова лінія 0.000 0.000 0.000 0.838
1. логістична, вихідні стовпці 0.013 0.200 0.007 0.839
2. + відхилення від типової 0.835 0.859 0.814 0.948
3. випадковий ліс 0.804 0.837 0.774 0.939
4. налаштована логістична 0.844 0.854 0.835 0.950
5. ТЕСТ (один раз) 0.796 0.841 0.755 0.937
крос-валідація обіцяла 0.844, тест дав 0.796 — на 0.048 менше.
Число на тесті нижче за крос-валідацію, і це не збіг. Ми обирали ознаки, модель, гіперпараметри й поріг за числом крос-валідації — а те, що обирали максимізуванням, завжди виходить трохи завищеним (тема 24). Тест такого відбору не бачив, тому й показує менше. Правильна очікувана якість у бою — 0.796, а не 0.844.
Друга причина конкретніша: у тесті 300 оголошень, із них 49 шахрайських. Одна помилка там важить у три рази більше, ніж у навчальній частині з 900 рядків.
11 · Чи можна це запускати¶
Технічна частина закінчилась. Далі — рішення, і воно не про метрики.
Спершу перевіримо здогад із розділу 8: чи справді якість тримається на оголошеннях із ціною й провалюється на решті.
є_ціна = (тест_F["ціна_відома"] == 1).values
print(f"оголошення З ціною : {int(є_ціна.sum()):3d}"
f" · F1 = {f1_score(тест_y[є_ціна], прогноз_на_тесті[є_ціна]):.3f}"
f" · precision = {precision_score(тест_y[є_ціна], прогноз_на_тесті[є_ціна]):.3f}"
f" · recall = {recall_score(тест_y[є_ціна], прогноз_на_тесті[є_ціна]):.3f}")
print(f"оголошення БЕЗ ціни: {int((~є_ціна).sum()):3d}"
f" · F1 = {f1_score(тест_y[~є_ціна], прогноз_на_тесті[~є_ціна]):.3f}"
f" · шахрайських серед них: {int(тест_y[~є_ціна].sum())}")
оголошення З ціною : 272 · F1 = 0.900 · precision = 0.964 · recall = 0.844 оголошення БЕЗ ціни: 28 · F1 = 0.606 · шахрайських серед них: 17
Здогад підтвердився на даних, яких модель не бачила: 0.900 проти 0.606. Отже рішення про запуск не одне, а два:
- оголошення з ціною (91 % потоку) — модель вирішує сама, F1 = 0.900 при precision 0.964: з 28 піднятих тривог 27 виявились справжніми;
- оголошення без ціни (9 % потоку) — у чергу до модератора без моделі. Не тому, що модель погана, а тому, що в цих рядках немає з чого робити висновок.
Що ще треба вирішити до запуску, і жодне з цих питань не має відповіді в коді:
- Що робить система з тривогою. Блокувати оголошення чи показувати попередження? Ціна помилки в цих двох варіантах відрізняється в рази.
- Скільки коштує підтримка. Карта типових цін застаріє: телефони дешевшають, виходять нові моделі. Її треба перераховувати — і хтось має за це відповідати.
- Як зрозуміти, що модель зіпсувалась. Шахраї змінюють поведінку — саме тому, що їх почали ловити. Модель, навчена на «дешева приманка», перестане працювати, щойно вони почнуть ставити ціну біля типової. Потрібен постійний замір на свіжих розмічених оголошеннях, а не одна цифра з дня запуску.
- Що з тими, кого образили. 7 хибних тривог на 300 оголошень — це живі люди. Потрібен спосіб оскаржити рішення, і його треба спроєктувати разом із моделлю.
Модель, яку запустили, не лишається такою ж доброю назавжди. Вона деградує — завжди, у всіх, і питання лише в тому, чи хтось це помітить.
12 · Чекліст проєкту¶
ПОСТАНОВКА
[ ] що саме передбачаємо і в який момент часу
[ ] хто споживає прогноз і що з ним робить
[ ] чого коштує помилка кожного типу
[ ] метрика обрана ДО навчання
ПОДІЛ
[ ] тест відкладено першою дією, до розвідки
[ ] stratify на рідкому класі
[ ] повні дублікати прибрано ДО поділу
РОЗВІДКА
[ ] тільки на навчальній частині
[ ] пропуски: скільки, де, чи повʼязані з таргетом
[ ] жодна колонка не знає майбутнього (перевір підозріло високу кореляцію)
ВІДЛІК
[ ] базова лінія порахована ПЕРШОЮ
[ ] будь-яке число завжди подається в парі з нею
ЦИКЛ
[ ] уся підготовка даних — усередині конвеєра
[ ] крос-валідація, а не один поділ
[ ] один крок — одна зміна — одне число
[ ] невдалі спроби записано, а не забуто
ПОМИЛКИ
[ ] подивився на конкретні обʼєкти, де модель помилилась
[ ] знайшов спільне у групах помилок
ФІНАЛ
[ ] поріг обрано на крос-валідації
[ ] тест відкрито рівно один раз
[ ] очікувана якість у бою = число з тесту, не з крос-валідації
ЗАПУСК
[ ] вирішено, що система робить із тривогою
[ ] є спосіб оскаржити рішення
[ ] є план заміру якості після запуску
Завдання¶
🟢 Рівень 1 — база¶
Пройди шлях ще раз, змінивши лише поріг: візьми фінальну модель і поміряй на тесті F1, precision і recall при порогах 0.3, 0.5 і 0.7. Для кожного порогу напиши, скільки шахраїв пропущено й скільки чесних продавців потурбовано.
Зроблено, якщо є таблиця з трьох рядків і одне речення про те, який поріг ти б обрав для дошки оголошень і чому.
🟡 Рівень 2 — плюс¶
Додай до додати_ознаки ще одну ознаку — «скільки оголошень у групі модель + рік»
(підказка: groupby(...).size(), карта будується на навч_X). Здогад: у маленьких
групах медіана шумна, тому «відхилення» там ненадійне, і модель має про це знати.
Виміряй оцінити() до і після. Якщо приросту немає — так і запиши: це нормальний
результат, а не привід ховати спробу.
Зроблено, якщо є два числа F1 (до і після) і чесний висновок, спрацювала ознака чи ні.
🔴 Рівень 3 — виклик¶
Проведи власний наскрізний проєкт на іншому таргеті тієї самої дошки: передбач не
шахрайство, а чи буде ціна вищою за типову для цієї моделі й року (таргет
(ціна > типова_ціна).astype(int), рядки без ціни викинь).
Пройди всі кроки в тому самому порядку: постановка → поділ → базова лінія → перша модель → щонайменше дві ітерації → аналіз помилок → тест один раз.
Увага, тут є пастка: ознаку відхилення брати не можна — вона побудована з тієї
самої різниці, що й новий таргет, і буде витоком. Знайди, які ознаки лишаються
законними.
Зроблено, якщо всі кроки пройдено з числами, витік не потрапив в ознаки, і фінальний результат чесно порівняно з базовою лінією.