Практика 02 · Дані: ознаки, таргет, типи задач¶
📖 Теорія: відкрий
lecture.htmlу цій же теці. 📝 Домашнє завдання:homework.md· 🧪 Тест:quiz.html
Наскрізний приклад той самий, що в лекції та в темі 01: дошка оголошень про продаж вживаних телефонів. Таблиця не зміниться жодного разу — змінюватиметься лише те, який стовпець ми називаємо відповіддю.
Що зробимо:
- Зберемо дошку оголошень у
pandasі роздивимось типи всіх семи колонок - Візьмемо таргетом
price— вийде регресія - Візьмемо таргетом
is_fraudна тих самих даних — вийде бінарна класифікація - Візьмемо таргетом
condition— вийде багатокласова класифікація - Порівняємо три постановки в одній таблиці
- Навмисно влаштуємо витік: додамо ознаку, що підглядає у відповідь
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.metrics import mean_absolute_error, r2_score, accuracy_score, roc_auc_score
# один і той самий генератор — щоб числа в тебе збіглися з числами тут
rng = np.random.default_rng(42)
print("numpy ", np.__version__)
print("pandas ", pd.__version__)
print("генератор випадкових чисел зафіксовано: default_rng(42)")
numpy 2.5.1 pandas 2.3.3 генератор випадкових чисел зафіксовано: default_rng(42)
1 · Дошка оголошень¶
Спершу — «правда», якої в реальних даних ми не бачимо: справедлива ціна телефона, що складається з року випуску, обсягу памʼяті й стану. Вона потрібна лише для того, щоб згенерувати правдоподібні оголошення.
N_ADS = 700
FRAUD_SHARE = 0.25 # чверть оголошень на дошці — шахрайські
phone_models = np.array(["Galaxy A54", "iPhone 12", "Redmi Note 12", "Pixel 7"])
condition_names = np.array(["потертий", "добрий", "новий"])
condition_bonus = {"новий": 1500, "добрий": 0, "потертий": -1200}
year = rng.integers(2018, 2024, N_ADS)
memory_gb = rng.choice([64, 128, 256], N_ADS)
condition = rng.choice(condition_names, N_ADS)
model_name = rng.choice(phone_models, N_ADS)
fair_price = (2900
+ 1750 * (year - 2018)
+ 21 * (memory_gb - 64)
+ np.array([condition_bonus[c] for c in condition])
+ rng.normal(0, 500, N_ADS))
is_fraud = (rng.random(N_ADS) < FRAUD_SHARE).astype(int)
print("згенеровано оголошень:", N_ADS)
print("шахрайських серед них:", int(is_fraud.sum()))
print("справедлива ціна: від", int(fair_price.min()), "до", int(fair_price.max()), "грн")
згенеровано оголошень: 700 шахрайських серед них: 173 справедлива ціна: від 971 до 17888 грн
Тепер ставимо ціну в оголошенні й вік акаунта продавця. Шахрай робить дві речі: ставить
ціну помітно нижчу за справедливу і публікує оголошення зі свіжого акаунта. Але робить
це не завжди — інакше задача розвʼязувалась би одним if.
# шахрайська знижка глибока, чесний торг — дрібний
discount = np.where(is_fraud == 1,
rng.uniform(0.35, 0.95, N_ADS),
rng.uniform(0.85, 1.15, N_ADS))
price = np.round(fair_price * discount, -1) # округлюємо до десятків гривень
account_age_days = np.where(is_fraud == 1,
rng.integers(0, 60, N_ADS),
rng.integers(0, 400, N_ADS))
ads = pd.DataFrame({
"model": model_name,
"year": year,
"condition": condition,
"memory_gb": memory_gb,
"account_age_days": account_age_days,
"price": price.astype(int),
"is_fraud": is_fraud,
})
print(ads.head(8).to_string(index=False))
model year condition memory_gb account_age_days price is_fraud
Pixel 7 2018 новий 128 207 6070 0
Redmi Note 12 2022 потертий 128 289 11070 0
Galaxy A54 2021 потертий 64 32 2730 1
Galaxy A54 2020 потертий 64 9 6410 0
Pixel 7 2020 потертий 64 147 5320 0
iPhone 12 2023 потертий 128 12 9180 1
Pixel 7 2018 добрий 64 258 2670 0
Galaxy A54 2022 новий 128 41 4920 1
2 · Рядок — обʼєкт, стовпець — ознака¶
Найважливіше про форму даних: один рядок описує рівно один обʼєкт. У нас обʼєкт — це одне оголошення. Не продавець, не модель телефона, не день.
Подивімось на розмір таблиці й на те, які типи даних у неї потрапили.
print("рядків (обʼєктів):", ads.shape[0])
print("стовпців (колонок):", ads.shape[1])
print()
print("типи даних, які побачив pandas:")
print(ads.dtypes.to_string())
рядків (обʼєктів): 700 стовпців (колонок): 7 типи даних, які побачив pandas: model object year int64 condition object memory_gb int64 account_age_days int64 price int64 is_fraud int64
3 · Типи колонок очима ML, а не очима pandas¶
dtype у pandas каже, як значення зберігається в памʼяті. Нас цікавить інше: які
операції над значеннями мають зміст. Найкорисніше число тут — скільки різних значень
має колонка: саме воно вирішує, чим стане задача, якщо взяти цю колонку таргетом.
# «на значення» — скільки рядків у середньому припадає на одне унікальне значення
опис_колонок = pd.DataFrame({
"унікальних": [ads[c].nunique() for c in ads.columns],
"приклад": [str(ads[c].iloc[0]) for c in ads.columns],
"тип ознаки": ["категорійна", "числова", "порядкова",
"числова (лише 3 значення)", "числова", "числова", "бінарна"],
}, index=ads.columns)
опис_колонок["на значення"] = (len(ads) / опис_колонок["унікальних"]).round(1)
print(опис_колонок.to_string())
унікальних приклад тип ознаки на значення model 4 Pixel 7 категорійна 175.0 year 6 2018 числова 116.7 condition 3 новий порядкова 233.3 memory_gb 3 128 числова (лише 3 значення) 233.3 account_age_days 315 207 числова 2.2 price 551 6070 числова 1.3 is_fraud 2 0 бінарна 350.0
Уже з цієї таблиці видно майбутнє трьох задач: is_fraud має 2 значення, condition — 3,
а price — сотні. Перші дві — класифікація, третя — регресія.
4 · Підготовка ознак¶
Одна функція на всі три задачі. Вона приймає таблицю й назву таргета, викидає цей стовпець із ознак і по-різному кодує різні типи:
condition— порядкова: потертий < добрий < новий. Кодуємо числами 0, 1, 2, щоб не втратити порядок.model— категорійна: порядку між моделями немає, тож робимо окремий стовпець-прапорець на кожну модель (pd.get_dummies).- решта — числові, беремо як є.
CONDITION_ORDER = {"потертий": 0, "добрий": 1, "новий": 2}
def prepare_features(table, target_name):
# повертає ознаки для заданого таргета: сам таргет із ознак прибрано
features = table.drop(columns=[target_name])
# порядкова ознака: зберігаємо порядок числами, бо стан телефона впорядкований
if "condition" in features.columns:
features["condition"] = features["condition"].map(CONDITION_ORDER)
# категорійна ознака: порядку немає, тому окремий прапорець на кожну модель
if "model" in features.columns:
features = pd.get_dummies(features, columns=["model"])
return features
ознаки_для_ціни = prepare_features(ads, "price")
print("таргет: price")
print("ознак вийшло:", ознаки_для_ціни.shape[1])
print("їхні назви:", list(ознаки_для_ціни.columns))
таргет: price ознак вийшло: 9 їхні назви: ['year', 'condition', 'memory_gb', 'account_age_days', 'is_fraud', 'model_Galaxy A54', 'model_Pixel 7', 'model_Redmi Note 12', 'model_iPhone 12']
5 · Таргет = price → регресія¶
Ціна набирає сотні різних значень, і на кожне окреме припадає близько одного прикладу. Класів тут немає — є шкала. Отже, регресія: модель має видати число, а міряти її треба помилкою в гривнях.
⚠️ Зверни увагу: серед ознак опинився
is_fraud. Механічно це правильно — усе, що не таргет, стає ознакою. Але в житті так робити не можна: у момент, коли продавець тільки складає оголошення, позначки модератора ще не існує. Ми лишаємо її тут навмисно, щоб набір ознак у трьох задачах був однаковий; у розділі 9 буде видно, чим саме такі колонки небезпечні.
X_price = prepare_features(ads, "price")
y_price = ads["price"]
X_train, X_test, y_train, y_test = train_test_split(
X_price, y_price, test_size=0.25, random_state=42)
price_model = LinearRegression()
price_model.fit(X_train, y_train)
price_pred = price_model.predict(X_test)
price_mae = mean_absolute_error(y_test, price_pred)
price_r2 = r2_score(y_test, price_pred)
print("модель: LinearRegression")
print(f"MAE: {price_mae:8.1f} грн (у середньому стільки промахуємось)")
print(f"R²: {price_r2:8.3f}")
print(f"середня ціна в тесті: {y_test.mean():.0f} грн")
модель: LinearRegression MAE: 926.0 грн (у середньому стільки промахуємось) R²: 0.899 середня ціна в тесті: 8154 грн
Перевірка: наша формула = бібліотечна¶
MAE — це середнє абсолютне відхилення, тобто «на скільки гривень модель помиляється
в середньому». Порахуймо його руками й переконаймось, що всередині mean_absolute_error
немає магії.
# рахуємо помилку окремо для кожного оголошення, щоб побачити, з чого складається MAE
помилки = np.abs(y_test.to_numpy() - price_pred)
наш_mae = помилки.mean()
print("наш MAE: ", round(наш_mae, 4))
print("бібліотечний MAE:", round(price_mae, 4))
assert np.allclose(наш_mae, price_mae), "розрахунок розійшовся!"
print("✅ збігається")
наш MAE: 925.966 бібліотечний MAE: 925.966 ✅ збігається
6 · Таргет = is_fraud → бінарна класифікація¶
Ті самі 700 рядків. Ми не додали жодної колонки й не змінили жодного значення — просто
назвали відповіддю інший стовпець. Тепер price став ознакою, а відповідь набуває
двох значень. Це вже класифікація, і метрика потрібна інша: гривні тут ні до чого.
X_fraud = prepare_features(ads, "is_fraud")
y_fraud = ads["is_fraud"]
Xf_train, Xf_test, yf_train, yf_test = train_test_split(
X_fraud, y_fraud, test_size=0.25, random_state=42, stratify=y_fraud)
fraud_model = LogisticRegression(max_iter=2000)
fraud_model.fit(Xf_train, yf_train)
fraud_pred = fraud_model.predict(Xf_test)
fraud_proba = fraud_model.predict_proba(Xf_test)[:, 1]
fraud_acc = accuracy_score(yf_test, fraud_pred)
fraud_auc = roc_auc_score(yf_test, fraud_proba)
print("модель: LogisticRegression")
print("ознаки: ", list(X_fraud.columns))
print(f"accuracy: {fraud_acc:.3f}")
print(f"ROC-AUC: {fraud_auc:.3f}")
print(f"частка чесних у тесті: {1 - yf_test.mean():.3f} ← стільки дала б модель, "
f"яка завжди каже «чесне»")
модель: LogisticRegression ознаки: ['year', 'condition', 'memory_gb', 'account_age_days', 'price', 'model_Galaxy A54', 'model_Pixel 7', 'model_Redmi Note 12', 'model_iPhone 12'] accuracy: 0.943 ROC-AUC: 0.973 частка чесних у тесті: 0.754 ← стільки дала б модель, яка завжди каже «чесне»
Зверни увагу на останній рядок. Класи незбалансовані: чесних оголошень утричі більше,
ніж шахрайських. Тому сама лише accuracy тут обманює — модель, яка взагалі нічого
не вміє й завжди каже «чесне», уже має досить високий показник. Саме через це для
незбалансованих задач беруть ROC-AUC або F1. Детально це розбирає тема
05. Precision і Recall.
7 · Таргет = condition → багатокласова класифікація¶
Третя постановка на тих самих даних. Стан телефона має три значення, і на кожне припадає близько двохсот прикладів — класифікація в чистому вигляді.
X_cond = prepare_features(ads, "condition")
y_cond = ads["condition"]
Xc_train, Xc_test, yc_train, yc_test = train_test_split(
X_cond, y_cond, test_size=0.25, random_state=42, stratify=y_cond)
cond_model = DecisionTreeClassifier(max_depth=5, random_state=42)
cond_model.fit(Xc_train, yc_train)
cond_pred = cond_model.predict(Xc_test)
cond_acc = accuracy_score(yc_test, cond_pred)
print("модель: DecisionTreeClassifier(max_depth=5)")
print(f"accuracy: {cond_acc:.3f}")
print()
print("скільки яких відповідей модель дала на тесті:")
print(pd.Series(cond_pred).value_counts().to_string())
print()
print("а скільки їх було насправді:")
print(yc_test.value_counts().to_string())
модель: DecisionTreeClassifier(max_depth=5) accuracy: 0.394 скільки яких відповідей модель дала на тесті: потертий 88 добрий 66 новий 21 а скільки їх було насправді: condition потертий 63 добрий 57 новий 55
Результат слабкий — і це нормально. Стан телефона майже не відновлюється з року, памʼяті та ціни: у даних просто немає закономірності, яку тут можна знайти. Це важливіший урок, ніж здається: коректно поставлена задача не зобовʼязана бути розвʼязною.
8 · Три постановки поруч¶
Складімо все в одну таблицю. Датасет один, рядків 700, ознак щоразу шість.
підсумок = pd.DataFrame([
{"таргет": "price", "унікальних": ads["price"].nunique(),
"тип задачі": "регресія", "модель": "LinearRegression",
"метрика": "MAE, грн", "значення": f"{price_mae:.0f}"},
{"таргет": "is_fraud", "унікальних": ads["is_fraud"].nunique(),
"тип задачі": "бінарна класифікація", "модель": "LogisticRegression",
"метрика": "ROC-AUC", "значення": f"{fraud_auc:.3f}"},
{"таргет": "condition", "унікальних": ads["condition"].nunique(),
"тип задачі": "багатокласова класифікація", "модель": "DecisionTree",
"метрика": "accuracy", "значення": f"{cond_acc:.3f}"},
])
print(підсумок.to_string(index=False))
print()
print("рядків у таблиці:", len(ads), "— однаково в усіх трьох задачах")
таргет унікальних тип задачі модель метрика значення
price 551 регресія LinearRegression MAE, грн 926
is_fraud 2 бінарна класифікація LogisticRegression ROC-AUC 0.973
condition 3 багатокласова класифікація DecisionTree accuracy 0.394
рядків у таблиці: 700 — однаково в усіх трьох задачах
Значення в останньому стовпці порівнювати між собою не можна: гривні й частки одиниці міряють різні речі в різних шкалах. Це і є практичний наслідок вибору таргета — разом із типом задачі змінюється навіть шкала, у якій ми говоримо про якість.
9 · Витік: ознака, що підглядає у відповідь¶
А тепер найкорисніша частина. Уяви, що в таблиці є ще одна колонка: days_until_removed —
скільки днів минуло від публікації до того, як модератор зняв оголошення. Виглядає
невинно, лежить у тій самій базі, додати її — один рядок коду.
Але значення цієї колонки зʼявляється після того, як хтось уже вирішив, що оголошення шахрайське. У момент, коли ми хочемо зробити передбачення, її ще не існує.
# оголошення, які зняли, — це шахрайські; чесні висять до кінця строку показу
days_until_removed = np.where(is_fraud == 1,
rng.integers(1, 15, N_ADS), # зняли за перші два тижні
999) # не знімали взагалі
ads_leaky = ads.copy()
ads_leaky["days_until_removed"] = days_until_removed
print(ads_leaky.groupby("is_fraud")["days_until_removed"].mean().round(1).to_string())
print()
print("↑ середнє значення нової колонки для чесних (0) і шахрайських (1) оголошень")
print("різниця між групами така, що колонку можна читати замість відповіді")
is_fraud 0 999.0 1 7.4 ↑ середнє значення нової колонки для чесних (0) і шахрайських (1) оголошень різниця між групами така, що колонку можна читати замість відповіді
Щоб порівняння було чесним, візьмімо одну й ту саму модель двічі: спершу на старих шести ознаках, потім — на тих самих шести плюс нова колонка. Дерево рішень тут зручне тим, що вміє показати, на що саме воно спиралось.
def навчити_дерево(таблиця):
# одна процедура на обидва прогони, щоб різниця була тільки в наборі ознак
X = prepare_features(таблиця, "is_fraud")
y = таблиця["is_fraud"]
X_tr, X_te, y_tr, y_te = train_test_split(
X, y, test_size=0.25, random_state=42, stratify=y)
дерево = DecisionTreeClassifier(max_depth=5, random_state=42)
дерево.fit(X_tr, y_tr)
точність = accuracy_score(y_te, дерево.predict(X_te))
auc = roc_auc_score(y_te, дерево.predict_proba(X_te)[:, 1])
return дерево, X.columns, точність, auc
чесне_дерево, чесні_ознаки, чесна_точність, чесний_auc = навчити_дерево(ads)
витік_дерево, витік_ознаки, витік_точність, витік_auc = навчити_дерево(ads_leaky)
print(" accuracy ROC-AUC")
print(f"без витоку {чесна_точність:.3f} {чесний_auc:.3f}")
print(f"з витоком {витік_точність:.3f} {витік_auc:.3f}")
accuracy ROC-AUC без витоку 0.937 0.959 з витоком 1.000 1.000
Ось як виглядає підозріло ідеальний результат. Формально все чесно: дані поділені на навчальні й тестові, модель бачила лише навчальні, метрика порахована правильно. І все одно число брехливе — бо в житті цієї колонки в потрібний момент не існує.
Перше правило: якщо метрика підозріло ідеальна, шукай витік, а не радій.
Тепер подивімось, чого це коштувало решті ознак.
важливість = pd.Series(витік_дерево.feature_importances_, index=витік_ознаки)
важливість = важливість.sort_values(ascending=False)
print("на що спиралось дерево з витоком:")
print(важливість.round(4).to_string())
print()
print(f"частка, яку забрала одна колонка: {важливість.iloc[0]:.1%}")
на що спиралось дерево з витоком: days_until_removed 1.0 year 0.0 condition 0.0 memory_gb 0.0 account_age_days 0.0 price 0.0 model_Galaxy A54 0.0 model_Pixel 7 0.0 model_Redmi Note 12 0.0 model_iPhone 12 0.0 частка, яку забрала одна колонка: 100.0%
Друге правило: одна ознака важить більше за всі решта разом — теж сигнал витоку.
І це не лише зіпсована перевірка. Модель із витоком справді гірша: коли одна колонка дає відповідь, у моделі немає причини розбиратись у ціні та віці акаунта — і вона в них і не розібралась. Прибери завтра цю колонку з даних, і в моделі не лишиться нічого.
Порівняймо два прогони на одному графіку.
підписи = ["без витоку", "з витоком"]
значення = [чесний_auc, витік_auc]
plt.figure(figsize=(6, 3.2))
смуги = plt.bar(підписи, значення, color=["#0f766e", "#c2185b"])
plt.bar_label(смуги, fmt="%.3f", padding=3)
plt.ylim(0.5, 1.08)
plt.ylabel("ROC-AUC на тесті")
plt.title("Одна колонка, яка знає відповідь")
plt.tight_layout()
plt.show()
print("Праворуч — не краща модель, а зіпсована перевірка.")
Праворуч — не краща модель, а зіпсована перевірка.
Як ловити витік до того, як він тебе спіймає¶
- Підозріло висока якість — перший сигнал. Якщо задача була важкою, а метрика раптом майже ідеальна, шукай ознаку, що знає відповідь.
- Одна ознака важить більше за всі решта разом — другий сигнал.
Подивись
feature_importances_або коефіцієнти, перш ніж радіти метриці. - Про кожну колонку постав питання: чи була б вона відома в момент передбачення? Якщо значення зʼявляється після відповіді — колонку треба прибрати.
Те саме стосується колонок із датою в задачах передбачення: posted_at, порядковий
номер запису, автоінкрементний id. Про це — розділ 05 лекції.
🎯 Завдання¶
🟢 Рівень 1 — База¶
Візьми таргетом year (рік випуску). Це шість цілих значень — за правилом «2–10 варіантів»
задача класифікаційна, але значення впорядковані.
- Навчи
DecisionTreeClassifierі виміряйaccuracy. - Навчи
LinearRegression, округли передбачення до цілого і виміряй, у якій частці випадків рік вгадано точно. - Порівняй два числа й скажи словами, який підхід тут кращий і чому.
🟡 Рівень 2 — Плюс¶
Перевір, скільки коштує неправильне кодування типів ознак.
- Закодуй
conditionяк категорійну (pd.get_dummies) замість порядкової і переучи модель ціни. - Закодуй
modelяк порядкову (просто пронумеруй моделі числами 0, 1, 2, 3) замість категорійної і переучи ще раз. - Склади таблицю з трьох рядків: правильне кодування, помилка в
condition, помилка вmodel. Порівняй MAE.
🔴 Рівень 3 — Виклик¶
Побудуй власний витік — такий, що його важче помітити, ніж days_until_removed.
- Додай колонку
price_ratio— відношення ціни оголошення до медіанної ціни всієї таблиці, порахованої по всіх 700 рядках одразу. - Навчи модель шахрайства й порівняй ROC-AUC із моделлю без цієї колонки.
- Тепер зроби чесно: порахуй медіану лише на навчальній вибірці і застосуй її до тестової. Порівняй ROC-AUC утретє.
- Поясни в двох реченнях, чому перший варіант дає завищене число, хоча колонка
price_ratioсама по собі цілком законна ознака.
Підказка до рівня 3: різниця буде невеликою — і це головне, що треба побачити. Витік не завжди дає accuracy 0,99; частіше він додає пару сотих, які потім не відтворюються в житті.