Практика · Інженерія ознак¶
Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.md
Беремо ту саму дошку оголошень про вживані телефони, що й у темі 08, і робимо з неї ознаки, яких у ній немає:
- збираємо таблицю й дві колонки з повного вивантаження — момент публікації та продавця;
- рахуємо точку відліку: наскільки корисні вихідні стовпці;
- вводимо три лінійки — кореляція, AUC, взаємна інформація — і перевіряємо власну AUC проти бібліотечної;
- будуємо відношення й агрегати через
groupbyі міряємо приріст; - розбираємо дату на складові та кодуємо годину синусом і косинусом;
- дивимось на взаємодію двох ознак, кожна з яких окремо безсила;
- власноруч влаштовуємо витік у цільовому кодуванні — і виправляємо його.
Усі числа тут ті самі, що в лекції: генератор випадкових чисел зафіксовано зерном 42.
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
кількість = 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) ✅ форма збігається
2 · Чистимо й добираємо дві колонки¶
Прибираємо те, що тема 08 навчила прибирати: суфікс «ГБ» у памʼяті та 12 повних дублікатів. Після цього в таблиці 1 200 рядків.
Далі беремо з повного вивантаження дошки дві колонки, яких ми досі не чіпали: момент публікації та ідентифікатор продавця. Саме з них вийдуть найцікавіші ознаки.
дошка["памʼять_гб"] = 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), тому в решті частка шахрайських трохи менша. Пишемо це прямо, а не мовчимо.
таблиця = дошка[дошка["ціна"].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 ми зʼясували, що це витік.
вихідні = ["ціна", "рік", "памʼять_гб", "вік_акаунта"]
точка_відліку = таблиця[вихідні].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), бо напрямок ознаки нас не цікавить — лише сила. - взаємна інформація — наскільки менше невизначеності лишається в таргеті, якщо знати ознаку. Бачить і нелінійний звʼязок.
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 немає магії.
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:
нам потрібен стовпець тієї самої довжини, що й таблиця.
таблиця["типова_ціна"] = (таблиця.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
Тепер найцікавіше. Порівняємо сиру ціну з відношенням трьома лінійками одразу.
порівняння = 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 раза
Кореляція нульова, а звʼязок є¶
Кореляція нової ознаки — практично нуль, хоча взаємна інформація виросла вп'ятеро. Подивимось на розподіл відношення в розрізі таргета — і стане видно, чому.
кошики = [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
Шахрайські зібрались на обох краях: і серед дуже дешевих, і серед дуже дорогих. Кореляція такого не бачить — два горби по різні боки одиниці в сумі дають нахил нуль.
Виправляється це ще одним перекладом: беремо модуль логарифма відношення. Логарифм робить «удвічі дешевше» й «удвічі дорожче» однаковими за величиною, модуль складає два горби в один.
таблиця["відхилення"] = 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 · Ознаки, які не спрацювали¶
Три переклади з тією самою логікою — і всі три слабші. Це не привід їх ховати: результат «ознака нічого не дала» такий самий чесний, як і будь-який інший.
таблиця["ціна_за_гб"] = таблиця["ціна"] / таблиця["памʼять_гб"]
таблиця["різниця_з_медіаною"] = таблиця["ціна"] - таблиця["типова_ціна"]
таблиця["вік_телефона"] = 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 · Агрегати по продавцю¶
Групувати можна не тільки за моделлю. Найкорисніші агрегати зазвичай виходять по обʼєкту, який породжує рядки, — у нас це продавець.
таблиця["оголошень_продавця"] = таблиця.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 · Дата: сира проти розібраної¶
Спочатку — найпоширеніша помилка: перевести момент публікації в число.
таблиця["дата_числом"] = таблиця["опубліковано"].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
таблиця["місяць"] = таблиця["опубліковано"].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 стоять на протилежних краях шкали, хоча між ними одна хвилина. Кладемо годину на коло — записуємо її координати на циферблаті.
кут = 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 · Взаємодія: дві ознаки, кожна безсила¶
Найкраща ознака теми зроблена з двох чисел. Порахуємо, чого варте кожне окремо — і що дає їхнє відношення.
взаємодія = 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 стовпців чистого шуму — випадкових чисел, які не мають до шахрайства жодного стосунку за побудовою, — і подивимось, наскільки «корисним» виглядає найкращий із них.
генератор_шуму = 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 · Витік: цільове кодування¶
Колонка «продавець» має сотні різних значень. Спокуслива ідея — замінити кожного продавця часткою шахрайських оголошень серед його власних. Один рядок коду. Подивимось, що з цього вийде.
таблиця["продавець_наївно"] = (таблиця.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 випадкових міток, які не мають до шахрайства жодного стосунку за побудовою. Закодуємо його таргетом наївно — і подивимось на число.
шум = 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 — і аж тоді міряємо.
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 · Підсумкова таблиця¶
Усе, що ми зробили, в одному місці — від точки відліку до найкращої ознаки.
фінал = 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.