Практика · Інженерія ознак¶

Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md

Беремо ту саму дошку оголошень про вживані телефони, що й у темі 08, і робимо з неї ознаки, яких у ній немає:

  1. збираємо таблицю й дві колонки з повного вивантаження — момент публікації та продавця;
  2. рахуємо точку відліку: наскільки корисні вихідні стовпці;
  3. вводимо три лінійки — кореляція, AUC, взаємна інформація — і перевіряємо власну AUC проти бібліотечної;
  4. будуємо відношення й агрегати через groupby і міряємо приріст;
  5. розбираємо дату на складові та кодуємо годину синусом і косинусом;
  6. дивимось на взаємодію двох ознак, кожна з яких окремо безсила;
  7. власноруч влаштовуємо витік у цільовому кодуванні — і виправляємо його.

Усі числа тут ті самі, що в лекції: генератор випадкових чисел зафіксовано зерном 42.

In [1]:
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score
from sklearn.feature_selection import mutual_info_classif
from sklearn.model_selection import train_test_split

# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
rng = np.random.default_rng(42)

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

1 · Збираємо ту саму дошку¶

Цей блок — код із практики теми 08 без змін. Він потрібен, щоб числа збіглися до цифри: кожен виклик генератора має статись у тому самому порядку. Що тут відбувається й навіщо — детально розібрано там; тут ми тільки відтворюємо таблицю.

In [2]:
кількість = 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.]
In [3]:
шанс_шахрайства = 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
In [4]:
# усі шість неприємностей із теми 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)
✅ форма збігається

2 · Чистимо й добираємо дві колонки¶

Прибираємо те, що тема 08 навчила прибирати: суфікс «ГБ» у памʼяті та 12 повних дублікатів. Після цього в таблиці 1 200 рядків.

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

In [5]:
дошка["памʼять_гб"] = pd.to_numeric(дошка["памʼять_гб"].str.replace(" ГБ", "", regex=False))
дошка = дошка.drop_duplicates().reset_index(drop=True)
print("після чистки:", дошка.shape)

рядків = len(дошка)
шахрай = дошка["шахрайське"].values == 1

# момент публікації: чесні продають удень, шахраї — переважно вночі
день_року = rng.integers(0, 365, рядків)
година_дробова = np.where(шахрай,
                          rng.normal(1.0, 2.6, рядків) % 24,
                          np.clip(rng.normal(14.0, 3.6, рядків), 0, 23.99))
хвилина = rng.integers(0, 60, рядків)
дошка["опубліковано"] = (pd.Timestamp("2024-01-01")
                         + pd.to_timedelta(день_року, unit="D")
                         + pd.to_timedelta(година_дробова.astype(int), unit="h")
                         + pd.to_timedelta(хвилина, unit="m"))

# продавець: 500 акаунтів, з них 60 «ризикових» — саме з них іде більшість шахрайських оголошень
номер_продавця = rng.integers(0, 500, рядків)
з_ризикового = шахрай & (rng.random(рядків) < 0.8)
номер_продавця[з_ризикового] = rng.integers(0, 60, з_ризикового.sum())
дошка["продавець"] = np.array(["S" + str(n).zfill(3) for n in номер_продавця])

print(дошка[["модель", "рік", "ціна", "опубліковано", "продавець", "шахрайське"]].head(3))
після чистки: (1200, 8)
        модель   рік     ціна        опубліковано продавець  шахрайське
0  Beta 12 Pro  2017   3090.0 2024-07-31 12:48:00      S473           0
1      Alfa A7  2021   2810.0 2024-02-13 13:29:00      S341           0
2      Gamma X  2017  82000.0 2024-08-06 12:47:00      S409           0

Рядки без ціни поки відкладаємо¶

У 100 оголошеннях ціни немає, і заповнення пропусків — тема передобробки, а не наша. Працюємо з тими, у кого ціна є. Це не безневинний крок: пропуск у ціні сам по собі пов'язаний із шахрайством (тема 08), тому в решті частка шахрайських трохи менша. Пишемо це прямо, а не мовчимо.

In [6]:
таблиця = дошка[дошка["ціна"].notna()].reset_index(drop=True).copy()
таргет = таблиця["шахрайське"]

print("рядків з відомою ціною:", len(таблиця))
print("шахрайських серед них :", int(таргет.sum()),
      f"({таргет.mean() * 100:.1f} %)")
print("для порівняння, у всій таблиці:",
      f"{дошка['шахрайське'].mean() * 100:.1f} %")
рядків з відомою ціною: 1100
шахрайських серед них : 139 (12.6 %)
для порівняння, у всій таблиці: 16.2 %

3 · Точка відліку: чого варті вихідні стовпці¶

Перш ніж щось будувати, треба знати, з чим порівнювати. Рахуємо кореляцію кожного числового стовпця з таргетом. Колонку «скарг» не беремо: у темі 08 ми зʼясували, що це витік.

In [7]:
вихідні = ["ціна", "рік", "памʼять_гб", "вік_акаунта"]
точка_відліку = таблиця[вихідні].corrwith(таргет).round(3)
print("кореляція вихідних стовпців із таргетом:")
print(точка_відліку)
print()
print("найсильніший звʼязок:", round(точка_відліку.abs().max(), 3))
кореляція вихідних стовпців із таргетом:
ціна          -0.011
рік            0.040
памʼять_гб     0.076
вік_акаунта   -0.116
dtype: float64

найсильніший звʼязок: 0.116

4 · Три лінійки¶

Кореляція бачить лише монотонний звʼязок, тому їй одній вірити не можна. Додаємо ще дві міри:

  • AUC — площа під ROC-кривою (тема 06), якщо взяти саму ознаку за оцінку ризику. 0.5 — розподіли класів збігаються, 1.0 — не перетинаються. Ми беремо max(AUC, 1 - AUC), бо напрямок ознаки нас не цікавить — лише сила.
  • взаємна інформація — наскільки менше невизначеності лишається в таргеті, якщо знати ознаку. Бачить і нелінійний звʼязок.
In [8]:
def виміряти(значення, назва=""):
    '''Три числа про одну ознаку: кореляція, AUC і взаємна інформація з таргетом.'''
    значення = pd.Series(np.asarray(значення, dtype=float))
    кореляція = значення.corr(таргет)
    площа = roc_auc_score(таргет, значення)
    площа = max(площа, 1 - площа)          # напрямок не важливий, важлива сила
    інформація = mutual_info_classif(
        значення.values.reshape(-1, 1), таргет, random_state=0)[0]
    return {"ознака": назва, "кореляція": round(float(кореляція), 3),
            "AUC": round(float(площа), 3), "MI": round(float(інформація), 3)}

print(pd.DataFrame([виміряти(таблиця["ціна"], "ціна")]).to_string(index=False))
ознака  кореляція   AUC    MI
  ціна     -0.011 0.666 0.069

Перевірка: наша AUC проти бібліотечної¶

AUC можна порахувати без жодної ROC-кривої. Це ймовірність того, що випадково взяте шахрайське оголошення дістане вищу оцінку, ніж випадково взяте чесне, — а таку ймовірність дає формула через ранги. Переконаймось, що всередині roc_auc_score немає магії.

In [9]:
def наша_auc(ознака, мітки):
    '''AUC через ранги: частка пар (шахрайське, чесне), де шахрайське стоїть вище.'''
    ранги = pd.Series(np.asarray(ознака, dtype=float)).rank()   # однаковим значенням — середній ранг
    позитивних = int((мітки == 1).sum())
    негативних = int((мітки == 0).sum())
    сума_рангів = ранги[np.asarray(мітки) == 1].sum()
    # віднімаємо мінімально можливу суму рангів позитивних і ділимо на кількість пар
    return (сума_рангів - позитивних * (позитивних + 1) / 2) / (позитивних * негативних)

наша = наша_auc(таблиця["ціна"], таргет)
бібліотечна = roc_auc_score(таргет, таблиця["ціна"])
print("наша AUC       :", round(наша, 6))
print("roc_auc_score  :", round(бібліотечна, 6))

assert np.allclose(наша, бібліотечна), "розрахунок розійшовся!"
print("✅ збігається")
наша AUC       : 0.333971
roc_auc_score  : 0.333971
✅ збігається

5 · Перша ознака: ціна проти типової¶

«Типової ціни» в таблиці немає — її треба порахувати з даних. Беремо медіану всередині групи «модель + рік» через groupby і transform. Саме transform, а не agg: нам потрібен стовпець тієї самої довжини, що й таблиця.

In [10]:
таблиця["типова_ціна"] = (таблиця.groupby(["модель", "рік"])["ціна"]
                          .transform("median").round(0))
таблиця["відношення"] = таблиця["ціна"] / таблиця["типова_ціна"]

розміри_груп = таблиця.groupby(["модель", "рік"])["ціна"].size()
print("груп «модель + рік»:", len(розміри_груп),
      "· найменша:", int(розміри_груп.min()),
      "· медіанна:", int(розміри_груп.median()))
print()
print(таблиця.loc[[34], ["модель", "рік", "ціна", "типова_ціна", "відношення", "шахрайське"]]
      .round(2).to_string(index=False))
груп «модель + рік»: 48 · найменша: 7 · медіанна: 21

 модель  рік   ціна  типова_ціна  відношення  шахрайське
Beta 12 2022 1310.0       6740.0        0.19           1

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

In [11]:
порівняння = pd.DataFrame([
    виміряти(таблиця["ціна"], "ціна (вихідна)"),
    виміряти(таблиця["відношення"], "ціна ÷ типова"),
])
print(порівняння.to_string(index=False))
print()
print("кореляція майже не змінилась, а взаємна інформація виросла у",
      round(порівняння.loc[1, "MI"] / порівняння.loc[0, "MI"], 1), "раза")
        ознака  кореляція   AUC    MI
ціна (вихідна)     -0.011 0.666 0.069
 ціна ÷ типова      0.018 0.748 0.336

кореляція майже не змінилась, а взаємна інформація виросла у 4.9 раза

Кореляція нульова, а звʼязок є¶

Кореляція нової ознаки — практично нуль, хоча взаємна інформація виросла вп'ятеро. Подивимось на розподіл відношення в розрізі таргета — і стане видно, чому.

In [12]:
кошики = [0, 0.5, 0.8, 1.25, 2.0, 100]
підписи = ["дешевше за половину", "0.5-0.8", "біля типової", "1.25-2.0", "дорожче вдвічі+"]
таблиця["кошик"] = pd.cut(таблиця["відношення"], bins=кошики, labels=підписи)

зведення = pd.crosstab(таблиця["кошик"], таргет)
зведення.columns = ["чесних", "шахрайських"]
зведення["частка шахрайських, %"] = (
    зведення["шахрайських"] / (зведення["чесних"] + зведення["шахрайських"]) * 100).round(1)
print(зведення)
                     чесних  шахрайських  частка шахрайських, %
кошик                                                          
дешевше за половину       2           90                   97.8
0.5-0.8                 165           14                    7.8
біля типової            597            0                    0.0
1.25-2.0                186            0                    0.0
дорожче вдвічі+          11           35                   76.1

Шахрайські зібрались на обох краях: і серед дуже дешевих, і серед дуже дорогих. Кореляція такого не бачить — два горби по різні боки одиниці в сумі дають нахил нуль.

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

In [13]:
таблиця["відхилення"] = np.abs(np.log(таблиця["відношення"]))

підсумок = pd.DataFrame([
    виміряти(таблиця["ціна"], "ціна (вихідна)"),
    виміряти(таблиця["відношення"], "ціна ÷ типова"),
    виміряти(таблиця["відхилення"], "|ln(ціна ÷ типова)|"),
])
print(підсумок.to_string(index=False))
             ознака  кореляція   AUC    MI
     ціна (вихідна)     -0.011 0.666 0.069
      ціна ÷ типова      0.018 0.748 0.336
|ln(ціна ÷ типова)|      0.774 0.990 0.331

6 · Ознаки, які не спрацювали¶

Три переклади з тією самою логікою — і всі три слабші. Це не привід їх ховати: результат «ознака нічого не дала» такий самий чесний, як і будь-який інший.

In [14]:
таблиця["ціна_за_гб"] = таблиця["ціна"] / таблиця["памʼять_гб"]
таблиця["різниця_з_медіаною"] = таблиця["ціна"] - таблиця["типова_ціна"]
таблиця["вік_телефона"] = 2025 - таблиця["рік"]

невдачі = pd.DataFrame([
    виміряти(таблиця["ціна"], "ціна (вихідна)"),
    виміряти(таблиця["ціна_за_гб"], "ціна ÷ памʼять"),
    виміряти(таблиця["різниця_з_медіаною"], "ціна − медіана"),
    виміряти(таблиця["вік_телефона"], "вік телефона"),
])
print(невдачі.to_string(index=False))
print()
print("«ціна − медіана» слабша за відношення, бо залежить від масштабу:")
print("500 грн для Alfa A5 — половина ціни, для Gamma X Ultra — округлення.")
        ознака  кореляція   AUC    MI
ціна (вихідна)     -0.011 0.666 0.069
ціна ÷ памʼять     -0.021 0.684 0.063
ціна − медіана      0.011 0.702 0.160
  вік телефона     -0.040 0.535 0.017

«ціна − медіана» слабша за відношення, бо залежить від масштабу:
500 грн для Alfa A5 — половина ціни, для Gamma X Ultra — округлення.

7 · Агрегати по продавцю¶

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

In [15]:
таблиця["оголошень_продавця"] = таблиця.groupby("продавець")["ціна"].transform("size")
таблиця["сер_відхилення_продавця"] = (таблиця.groupby("продавець")["відхилення"]
                                      .transform("mean"))
таблиця["сер_година_продавця"] = (таблиця.groupby("продавець")["опубліковано"]
                                  .transform(lambda моменти: моменти.dt.hour.mean()))

агрегати = pd.DataFrame([
    виміряти(таблиця["сер_відхилення_продавця"], "сер. відхилення продавця"),
    виміряти(таблиця["сер_година_продавця"], "сер. година продавця"),
    виміряти(таблиця["оголошень_продавця"], "оголошень продавця"),
    виміряти(таблиця["типова_ціна"], "медіанна ціна моделі"),
])
print(агрегати.to_string(index=False))
print()
print("медіанна ціна моделі сама по собі марна (AUC біля 0.5),")
print("але саме вона стоїть у знаменнику найкращої ознаки теми.")
                  ознака  кореляція   AUC    MI
сер. відхилення продавця      0.611 0.928 0.151
    сер. година продавця     -0.293 0.703 0.089
      оголошень продавця      0.211 0.654 0.020
    медіанна ціна моделі     -0.034 0.512 0.006

медіанна ціна моделі сама по собі марна (AUC біля 0.5),
але саме вона стоїть у знаменнику найкращої ознаки теми.

8 · Дата: сира проти розібраної¶

Спочатку — найпоширеніша помилка: перевести момент публікації в число.

In [16]:
таблиця["дата_числом"] = таблиця["опубліковано"].astype("int64") / 86_400_000_000_000

print("те, що бачить модель у першому рядку:",
      round(таблиця.loc[0, "дата_числом"], 1), "днів від 1970 року")
print()
print(pd.DataFrame([виміряти(таблиця["дата_числом"], "дата одним числом")])
      .to_string(index=False))
те, що бачить модель у першому рядку: 19935.5 днів від 1970 року

           ознака  кореляція   AUC    MI
дата одним числом      0.044 0.539 0.017
In [17]:
таблиця["місяць"] = таблиця["опубліковано"].dt.month
таблиця["день_тижня"] = таблиця["опубліковано"].dt.dayofweek
таблиця["година"] = таблиця["опубліковано"].dt.hour
таблиця["вихідний"] = (таблиця["день_тижня"] >= 5).astype(int)

складові = pd.DataFrame([
    виміряти(таблиця["дата_числом"], "дата одним числом"),
    виміряти(таблиця["місяць"], "місяць"),
    виміряти(таблиця["день_тижня"], "день тижня"),
    виміряти(таблиця["вихідний"], "вихідний"),
    виміряти(таблиця["година"], "година"),
])
print(складові.to_string(index=False))
print()
print("медіанна година: чесні —", int(таблиця.loc[таргет == 0, "година"].median()),
      "· шахрайські —", int(таблиця.loc[таргет == 1, "година"].median()))
           ознака  кореляція   AUC    MI
дата одним числом      0.044 0.539 0.017
           місяць      0.042 0.536 0.000
       день тижня     -0.091 0.578 0.000
         вихідний     -0.071 0.549 0.000
           година     -0.332 0.684 0.291

медіанна година: чесні — 13 · шахрайські — 3

9 · Циклічне кодування години¶

Година як число бреше про відстані: 23 і 0 стоять на протилежних краях шкали, хоча між ними одна хвилина. Кладемо годину на коло — записуємо її координати на циферблаті.

In [18]:
кут = 2 * np.pi * таблиця["година"] / 24
таблиця["год_sin"] = np.sin(кут)
таблиця["год_cos"] = np.cos(кут)
таблиця["нічна_година"] = ((таблиця["година"] >= 22) | (таблиця["година"] <= 4)).astype(int)

# перевіряємо саму ідею: відстань між 23-ю і 0-ю на прямій і на колі
на_прямій = abs(23 - 0)
на_колі = np.hypot(np.sin(2*np.pi*23/24) - np.sin(0), np.cos(2*np.pi*23/24) - np.cos(0))
print("відстань між 23:00 і 00:00 на прямій шкалі:", на_прямій)
print("та сама відстань у координатах sin/cos    :", round(на_колі, 3))
print()

циклічне = pd.DataFrame([
    виміряти(таблиця["година"], "година (число)"),
    виміряти(таблиця["год_sin"], "sin(година)"),
    виміряти(таблиця["год_cos"], "cos(година)"),
    виміряти(таблиця["нічна_година"], "прапорець «ніч 22-04»"),
])
print(циклічне.to_string(index=False))
відстань між 23:00 і 00:00 на прямій шкалі: 23
та сама відстань у координатах sin/cos    : 0.261

               ознака  кореляція   AUC    MI
       година (число)     -0.332 0.684 0.291
          sin(година)      0.212 0.653 0.047
          cos(година)      0.727 0.982 0.262
прапорець «ніч 22-04»      0.839 0.903 0.234

10 · Взаємодія: дві ознаки, кожна безсила¶

Найкраща ознака теми зроблена з двох чисел. Порахуємо, чого варте кожне окремо — і що дає їхнє відношення.

In [19]:
взаємодія = pd.DataFrame([
    виміряти(таблиця["типова_ціна"], "A — типова ціна моделі"),
    виміряти(таблиця["ціна"], "Б — ціна оголошення"),
    виміряти(таблиця["відношення"], "A і Б: ціна ÷ типова"),
    виміряти(таблиця["відхилення"], "A і Б: |ln відношення|"),
])
print(взаємодія.to_string(index=False))
print()
print("жодна вісь окремо не розділяє класи, а разом вони дають майже ідеальний розподіл")
                ознака  кореляція   AUC    MI
A — типова ціна моделі     -0.034 0.512 0.006
   Б — ціна оголошення     -0.011 0.666 0.069
  A і Б: ціна ÷ типова      0.018 0.748 0.336
A і Б: |ln відношення|      0.774 0.990 0.331

жодна вісь окремо не розділяє класи, а разом вони дають майже ідеальний розподіл

11 · Чому «більше ознак» не означає «краще»¶

Зайва ознака здається безкоштовною: у найгіршому випадку її просто проігнорують. Перевіримо. Додамо 50 стовпців чистого шуму — випадкових чисел, які не мають до шахрайства жодного стосунку за побудовою, — і подивимось, наскільки «корисним» виглядає найкращий із них.

In [20]:
генератор_шуму = np.random.default_rng(7)
шумові_ознаки = генератор_шуму.normal(size=(len(таблиця), 50))

# кореляція кожного шумового стовпця з таргетом — усі мали б бути нулем
кореляції_шуму = np.array([
    abs(np.corrcoef(шумові_ознаки[:, номер], таргет)[0, 1]) for номер in range(50)])

print("найбільша |r| серед 50 шумових ознак:", round(кореляції_шуму.max(), 3))
print("середня  |r| серед 50 шумових ознак:", round(кореляції_шуму.mean(), 3))
print("скільки шумових ознак дали |r| > 0.05:", int((кореляції_шуму > 0.05).sum()))
print()
print("для порівняння, справжні ознаки:")
print("  |r| «день тижня»  :", round(abs(таблиця["день_тижня"].corr(таргет)), 3))
print("  |r| «вік телефона»:", round(abs(таблиця["вік_телефона"].corr(таргет)), 3))
print()
print("осмислена ознака «вік телефона» слабша за найкращий випадковий стовпець —")
print("чим більше ознак перебираєш, тим сильніший найкращий випадковий результат")
найбільша |r| серед 50 шумових ознак: 0.061
середня  |r| серед 50 шумових ознак: 0.026
скільки шумових ознак дали |r| > 0.05: 6

для порівняння, справжні ознаки:
  |r| «день тижня»  : 0.091
  |r| «вік телефона»: 0.04

осмислена ознака «вік телефона» слабша за найкращий випадковий стовпець —
чим більше ознак перебираєш, тим сильніший найкращий випадковий результат

12 · Витік: цільове кодування¶

Колонка «продавець» має сотні різних значень. Спокуслива ідея — замінити кожного продавця часткою шахрайських оголошень серед його власних. Один рядок коду. Подивимось, що з цього вийде.

In [21]:
таблиця["продавець_наївно"] = (таблиця.groupby("продавець")["шахрайське"]
                               .transform("mean"))

скільки_оголошень = таблиця.groupby("продавець")["шахрайське"].transform("size")
одинаки = скільки_оголошень == 1

print("продавців усього:", таблиця["продавець"].nunique())
print("оголошень від продавців з єдиним оголошенням:", int(одинаки.sum()))
print()
print("для них наївний код дорівнює таргету буквально:",
      bool((таблиця.loc[одинаки, "продавець_наївно"] == таргет[одинаки]).all()))
print("кореляція наївного коду з таргетом на цих рядках:",
      round(таблиця.loc[одинаки, "продавець_наївно"].corr(таргет[одинаки]), 3))
продавців усього: 440
оголошень від продавців з єдиним оголошенням: 121

для них наївний код дорівнює таргету буквально: True
кореляція наївного коду з таргетом на цих рядках: 1.0

Найпереконливіша демонстрація — на ознаці з чистого шуму¶

Додамо стовпець «код регіону»: 448 випадкових міток, які не мають до шахрайства жодного стосунку за побудовою. Закодуємо його таргетом наївно — і подивимось на число.

In [22]:
шум = np.random.default_rng(2024)
таблиця["код_регіону"] = ["R" + str(k).zfill(3) for k in шум.integers(0, 448, len(таблиця))]

наївний_код = таблиця.groupby("код_регіону")["шахрайське"].transform("mean")
print("наївне цільове кодування ВИПАДКОВОГО коду, кореляція:",
      round(наївний_код.corr(таргет), 3))
print("для порівняння, найкраща чесна ознака теми:",
      round(таблиця["відхилення"].corr(таргет), 3))
print()
print("ознака з чистого шуму виглядає майже такою ж сильною, як найкраща справжня")
наївне цільове кодування ВИПАДКОВОГО коду, кореляція: 0.57
для порівняння, найкраща чесна ознака теми: 0.774

ознака з чистого шуму виглядає майже такою ж сильною, як найкраща справжня

Як робити правильно¶

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

In [23]:
train, test = train_test_split(таблиця, test_size=0.3, random_state=42,
                               stratify=таргет)

загальне_середнє = train["шахрайське"].mean()

def чесний_код(стовпець):
    '''Карта «категорія → частка шахрайських» будується ТІЛЬКИ на train.'''
    карта = train.groupby(стовпець)["шахрайське"].mean()
    # категоріям, яких у train не було, підставляємо загальне середнє
    return test[стовпець].map(карта).fillna(загальне_середнє)

результат = pd.DataFrame([
    {"колонка": "код регіону (шум)",
     "наївно на test": round(наївний_код.loc[test.index].corr(test["шахрайське"]), 3),
     "чесно на test": round(чесний_код("код_регіону").corr(test["шахрайське"]), 3)},
    {"колонка": "продавець (справжня)",
     "наївно на test": round(таблиця.loc[test.index, "продавець_наївно"]
                             .corr(test["шахрайське"]), 3),
     "чесно на test": round(чесний_код("продавець").corr(test["шахрайське"]), 3)},
])
print(результат.to_string(index=False))
print()
print("продавців у test, яких не було в train:",
      int(test["продавець"].map(train.groupby("продавець")["шахрайське"].mean()).isna().sum()),
      "із", len(test))
             колонка  наївно на test  чесно на test
   код регіону (шум)           0.583         -0.071
продавець (справжня)           0.657          0.303

продавців у test, яких не було в train: 60 із 330

13 · Підсумкова таблиця¶

Усе, що ми зробили, в одному місці — від точки відліку до найкращої ознаки.

In [24]:
фінал = pd.DataFrame([
    виміряти(таблиця["ціна"], "ціна (вихідна)"),
    виміряти(таблиця["вік_акаунта"], "вік акаунта (вихідна)"),
    виміряти(таблиця["дата_числом"], "дата одним числом"),
    виміряти(таблиця["година"], "година як число"),
    виміряти(таблиця["оголошень_продавця"], "оголошень продавця"),
    виміряти(таблиця["сер_відхилення_продавця"], "сер. відхилення продавця"),
    виміряти(таблиця["год_cos"], "cos(година)"),
    виміряти(таблиця["відхилення"], "|ln(ціна ÷ типова)|"),
]).sort_values("AUC")
print(фінал.to_string(index=False))
print()
print("вихідні стовпці внизу, зроблені руками — угорі. Жодних нових даних не додано.")
                  ознака  кореляція   AUC    MI
       дата одним числом      0.044 0.539 0.017
   вік акаунта (вихідна)     -0.116 0.653 0.023
      оголошень продавця      0.211 0.654 0.020
          ціна (вихідна)     -0.011 0.666 0.069
         година як число     -0.332 0.684 0.291
сер. відхилення продавця      0.611 0.928 0.151
             cos(година)      0.727 0.982 0.262
     |ln(ціна ÷ типова)|      0.774 0.990 0.331

вихідні стовпці внизу, зроблені руками — угорі. Жодних нових даних не додано.

Завдання¶

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

Додай ознаку «ціна ÷ медіанна ціна цього продавця» (groupby("продавець")) і виміряй її трьома лінійками через виміряти(). Порівняй із сирою ціною.

Зроблено, якщо в тебе є рядок таблиці з трьома числами й одне речення про те, краща ця ознака за сиру ціну чи ні.

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

Побудуй ознаку «скільки годин минуло від попереднього оголошення цього продавця» (підказка: sort_values за продавцем і датою, потім groupby(...).diff()). Виміряй її. Потім поясни, чому ця ознака не є витоком із майбутнього, а ознака «скільки годин до наступного оголошення цього продавця» — була б ним.

Зроблено, якщо ознака порахована, виміряна, і різницю між двома формулюваннями пояснено двома-трьома реченнями.

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

Реалізуй чесне цільове кодування з виключенням себе (leave-one-out): для кожного рядка код продавця рахується як середній таргет його інших оголошень.

Формула: (сума таргета по групі − таргет цього рядка) / (розмір групи − 1), а для продавців з єдиним оголошенням — загальне середнє.

Порівняй три числа на тестовій частині: наївне кодування, кодування з виключенням себе, кодування по карті з train.

Зроблено, якщо всі три числа пораховані й ти можеш пояснити, чому кодування з виключенням себе все одно слабше захищає, ніж карта з train.