Практика 02 · Дані: ознаки, таргет, типи задач¶

📖 Теорія: відкрий lecture.html у цій же теці. 📝 Домашнє завдання: homework.md · 🧪 Тест: quiz.html

Наскрізний приклад той самий, що в лекції та в темі 01: дошка оголошень про продаж вживаних телефонів. Таблиця не зміниться жодного разу — змінюватиметься лише те, який стовпець ми називаємо відповіддю.

Що зробимо:

  1. Зберемо дошку оголошень у pandas і роздивимось типи всіх семи колонок
  2. Візьмемо таргетом price — вийде регресія
  3. Візьмемо таргетом is_fraud на тих самих даних — вийде бінарна класифікація
  4. Візьмемо таргетом condition — вийде багатокласова класифікація
  5. Порівняємо три постановки в одній таблиці
  6. Навмисно влаштуємо витік: додамо ознаку, що підглядає у відповідь
In [1]:
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 · Дошка оголошень¶

Спершу — «правда», якої в реальних даних ми не бачимо: справедлива ціна телефона, що складається з року випуску, обсягу памʼяті й стану. Вона потрібна лише для того, щоб згенерувати правдоподібні оголошення.

In [2]:
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.

In [3]:
# шахрайська знижка глибока, чесний торг — дрібний
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 · Рядок — обʼєкт, стовпець — ознака¶

Найважливіше про форму даних: один рядок описує рівно один обʼєкт. У нас обʼєкт — це одне оголошення. Не продавець, не модель телефона, не день.

Подивімось на розмір таблиці й на те, які типи даних у неї потрапили.

In [4]:
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 каже, як значення зберігається в памʼяті. Нас цікавить інше: які операції над значеннями мають зміст. Найкорисніше число тут — скільки різних значень має колонка: саме воно вирішує, чим стане задача, якщо взяти цю колонку таргетом.

In [5]:
# «на значення» — скільки рядків у середньому припадає на одне унікальне значення
опис_колонок = 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).
  • решта — числові, беремо як є.
In [6]:
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 буде видно, чим саме такі колонки небезпечні.

In [7]:
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 немає магії.

In [8]:
# рахуємо помилку окремо для кожного оголошення, щоб побачити, з чого складається 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 став ознакою, а відповідь набуває двох значень. Це вже класифікація, і метрика потрібна інша: гривні тут ні до чого.

In [9]:
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 → багатокласова класифікація¶

Третя постановка на тих самих даних. Стан телефона має три значення, і на кожне припадає близько двохсот прикладів — класифікація в чистому вигляді.

In [10]:
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, ознак щоразу шість.

In [11]:
підсумок = 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 — скільки днів минуло від публікації до того, як модератор зняв оголошення. Виглядає невинно, лежить у тій самій базі, додати її — один рядок коду.

Але значення цієї колонки зʼявляється після того, як хтось уже вирішив, що оголошення шахрайське. У момент, коли ми хочемо зробити передбачення, її ще не існує.

In [12]:
# оголошення, які зняли, — це шахрайські; чесні висять до кінця строку показу
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) оголошень
різниця між групами така, що колонку можна читати замість відповіді

Щоб порівняння було чесним, візьмімо одну й ту саму модель двічі: спершу на старих шести ознаках, потім — на тих самих шести плюс нова колонка. Дерево рішень тут зручне тим, що вміє показати, на що саме воно спиралось.

In [13]:
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

Ось як виглядає підозріло ідеальний результат. Формально все чесно: дані поділені на навчальні й тестові, модель бачила лише навчальні, метрика порахована правильно. І все одно число брехливе — бо в житті цієї колонки в потрібний момент не існує.

Перше правило: якщо метрика підозріло ідеальна, шукай витік, а не радій.

Тепер подивімось, чого це коштувало решті ознак.

In [14]:
важливість = 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%

Друге правило: одна ознака важить більше за всі решта разом — теж сигнал витоку.

І це не лише зіпсована перевірка. Модель із витоком справді гірша: коли одна колонка дає відповідь, у моделі немає причини розбиратись у ціні та віці акаунта — і вона в них і не розібралась. Прибери завтра цю колонку з даних, і в моделі не лишиться нічого.

Порівняймо два прогони на одному графіку.

In [15]:
підписи = ["без витоку", "з витоком"]
значення = [чесний_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("Праворуч — не краща модель, а зіпсована перевірка.")
No description has been provided for this image
Праворуч — не краща модель, а зіпсована перевірка.

Як ловити витік до того, як він тебе спіймає¶

  1. Підозріло висока якість — перший сигнал. Якщо задача була важкою, а метрика раптом майже ідеальна, шукай ознаку, що знає відповідь.
  2. Одна ознака важить більше за всі решта разом — другий сигнал. Подивись feature_importances_ або коефіцієнти, перш ніж радіти метриці.
  3. Про кожну колонку постав питання: чи була б вона відома в момент передбачення? Якщо значення зʼявляється після відповіді — колонку треба прибрати.

Те саме стосується колонок із датою в задачах передбачення: posted_at, порядковий номер запису, автоінкрементний id. Про це — розділ 05 лекції.


🎯 Завдання¶

🟢 Рівень 1 — База¶

Візьми таргетом year (рік випуску). Це шість цілих значень — за правилом «2–10 варіантів» задача класифікаційна, але значення впорядковані.

  1. Навчи DecisionTreeClassifier і виміряй accuracy.
  2. Навчи LinearRegression, округли передбачення до цілого і виміряй, у якій частці випадків рік вгадано точно.
  3. Порівняй два числа й скажи словами, який підхід тут кращий і чому.

🟡 Рівень 2 — Плюс¶

Перевір, скільки коштує неправильне кодування типів ознак.

  1. Закодуй condition як категорійну (pd.get_dummies) замість порядкової і переучи модель ціни.
  2. Закодуй model як порядкову (просто пронумеруй моделі числами 0, 1, 2, 3) замість категорійної і переучи ще раз.
  3. Склади таблицю з трьох рядків: правильне кодування, помилка в condition, помилка в model. Порівняй MAE.

🔴 Рівень 3 — Виклик¶

Побудуй власний витік — такий, що його важче помітити, ніж days_until_removed.

  1. Додай колонку price_ratio — відношення ціни оголошення до медіанної ціни всієї таблиці, порахованої по всіх 700 рядках одразу.
  2. Навчи модель шахрайства й порівняй ROC-AUC із моделлю без цієї колонки.
  3. Тепер зроби чесно: порахуй медіану лише на навчальній вибірці і застосуй її до тестової. Порівняй ROC-AUC утретє.
  4. Поясни в двох реченнях, чому перший варіант дає завищене число, хоча колонка price_ratio сама по собі цілком законна ознака.

Підказка до рівня 3: різниця буде невеликою — і це головне, що треба побачити. Витік не завжди дає accuracy 0,99; частіше він додає пару сотих, які потім не відтворюються в житті.