Практика · Передобробка даних> Лекція: lecture.html · Тест: quiz.html · Домашнє: homework.mdБеремо ту саму дошку оголошень, що й у темі 08, — з усімаїї дірками, дублікатами й викидами, — і проходимо кожне рішення передобробки по черзі,щоразу друкуючи наслідки:1. дублікати й типи — кроки, у яких вибору майже немає;2. пропуски: три шляхи, і скільки рядків лишається після кожного;3. прапорець ціни_немає — безкоштовна ознака з порожнечі;4. категорії: пряме проти порядкового кодування;5. масштаб: стандартизація й мінімакс своїми руками, звірені з scikit-learn;6. викиди: обрізання й логарифм;7. витік через передобробку — та сама оцінка при двох порядках дій;8. конвеєр, який робить усе це правильно сам.Зерно генератора зафіксовано (default_rng(42)), тому числа будуть точно ті самі,що в лекції.¶
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, MinMaxScaler, OneHotEncoder
from sklearn.metrics import precision_score, recall_score, f1_score
# зерно фіксує всю випадковість: у тебе вийдуть точно ті самі числа, що в лекції
rng = np.random.default_rng(42)
pd.set_option("display.width", 120)
pd.set_option("display.max_columns", 14)
print("numpy", np.__version__, "· pandas", pd.__version__)
numpy 2.5.1 · pandas 2.3.3
1 · Збираємо ту саму дошкуЦе повтор першої частини практики теми 08 — «чесні» оголошення, шахрайські приманкий шість реальних неприємностей поверх. Читати цей код уважно не обовʼязково: важливолише, що на виході отримаємо рівно ту таблицю, яку ми розвідали минулого разу.¶
кількість = 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}
модель = rng.choice(моделі, size=кількість, p=[0.24, 0.22, 0.18, 0.16, 0.12, 0.08])
рік = 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 - рік) # телефон дешевшає приблизно на 18 % за рік
коефіцієнт_стану = 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("типова ціна перших трьох:", типова_ціна[:3].round(0))
типова ціна перших трьох: [ 3763. 3060. 12071.]
Шахрай працює зі свіжого акаунта й ставить або різко занижену ціну («неймовірна знижка»),або завищену — під велику передоплату. Скарги надходять після того, як покупецьпостраждав: саме тому колонка «скарг» і виявилась витоком у темі 08.
шанс_шахрайства = 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("чиста таблиця:", дошка.shape, "· шахрайських:", int(дошка["шахрайське"].sum()))
чиста таблиця: (1200, 8) · шахрайських: 196
# 1. колекційні: запаковані флагмани семирічної давності, за них справді платять
колекційні = дошка.index[дошка["модель"] == "Gamma X"][:4]
дошка.loc[колекційні, ["рік", "стан", "памʼять_гб"]] = [2017, "нове", 512]
дошка.loc[колекційні, "ціна"] = [82000.0, 88000.0, 91000.0, 95000.0]
дошка.loc[колекційні, ["шахрайське", "скарг"]] = 0
# 2. одруки: у двох звичайних оголошень при введенні ціни додали зайвий нуль
одруки = дошка.index[(дошка["ціна"] > 7000) & (дошка["ціна"] < 9600)
& (дошка["шахрайське"] == 0)][:2]
дошка.loc[одруки, "ціна"] = дошка.loc[одруки, "ціна"] * 10
# 3. памʼять: у частині рядків одиниці виміру приїхали разом зі значенням
памʼять_текстом = дошка["памʼять_гб"].astype(str)
із_одиницями = rng.random(len(дошка)) < 0.18
памʼять_текстом[із_одиницями] = памʼять_текстом[із_одиницями] + " ГБ"
дошка["памʼять_гб"] = памʼять_текстом
# 4. ціна зникає в шахрайських оголошеннях набагато частіше, ніж у чесних
ймовірність_пропуску = np.where(дошка["шахрайське"] == 1, 0.25, 0.03)
дошка.loc[rng.random(len(дошка)) < ймовірність_пропуску, "ціна"] = np.nan
# 5. стан зникає просто так, без звʼязку з чим завгодно
дошка.loc[rng.random(len(дошка)) < 0.04, "стан"] = np.nan
# 6. дванадцять випадкових оголошень подані двічі
повтори = rng.choice(дошка.index, size=12, replace=False)
дошка = pd.concat([дошка, дошка.loc[повтори]], ignore_index=True)
print("та сама брудна таблиця, що в темі 08:", дошка.shape)
print(дошка.isna().sum()[lambda s: s > 0])
та сама брудна таблиця, що в темі 08: (1212, 8) стан 48 ціна 101 dtype: int64
---# Крок нульовий: те, у чому вибору немає## 2 · Дублікати й типи колонокЦі два кроки нічого не вивчають із даних: вони роблять із кожним рядком те саменезалежно від решти таблиці. Тому їх можна (і треба) робити першими — ще до будь-якогоподілу на навчальну й тестову частини.
до = len(дошка)
дошка = дошка.drop_duplicates().reset_index(drop=True)
print("дублікатів прибрано:", до - len(дошка), "· лишилось рядків:", len(дошка))
# суфікс « ГБ» треба відрізати ДО переведення в число: to_numeric із errors="coerce"
# мовчки перетворив би 215 справжніх значень на пропуски
дошка["памʼять_гб"] = pd.to_numeric(дошка["памʼять_гб"].str.replace(" ГБ", "", regex=False))
print("тип памʼяті тепер:", дошка["памʼять_гб"].dtype)
print("\nпропусків після цих двох кроків:")
print(дошка.isna().sum()[lambda s: s > 0])
дублікатів прибрано: 12 · лишилось рядків: 1200 тип памʼяті тепер: int64 пропусків після цих двох кроків: стан 48 ціна 100 dtype: int64
Зверни увагу: пропусків у ціні стало 100, а не 101, як було в темі 08. Один іздванадцяти дублікатів був рядком без ціни — і зник разом із дублікатом.---# Пропуски## 3 · Шлях перший: викинути рядки з діркамиНайчесніший на вигляд варіант. Порахуймо не лише скільки рядків лишиться, а й які саме.
повні_рядки = дошка.dropna()
print("рядків було :", len(дошка))
print("рядків лишилось :", len(повні_рядки))
print("втрачено :", len(дошка) - len(повні_рядки))
print()
print("шахрайських було :", int(дошка["шахрайське"].sum()))
print("шахрайських лишилось :", int(повні_рядки["шахрайське"].sum()))
print("частка шахрайських: %.2f %% → %.2f %%"
% (дошка["шахрайське"].mean() * 100, повні_рядки["шахрайське"].mean() * 100))
рядків було : 1200 рядків лишилось : 1055 втрачено : 145 шахрайських було : 195 шахрайських лишилось : 135 частка шахрайських: 16.25 % → 12.80 %
Разом із рядками пішла майже третина шахрайських оголошень — тобто саме те, заради чогозадача взагалі існує. Причина в тому, що пропуски не випадкові: перевіримо це числом.
# частка шахрайських окремо серед рядків із пропуском і без нього
for стовпець in ["ціна", "стан"]:
немає = дошка[стовпець].isna()
print("%-6s: значення є — %5.1f %% шахрайських (%d рядків) · "
"значення немає — %5.1f %% (%d рядків)"
% (стовпець,
дошка.loc[~немає, "шахрайське"].mean() * 100, int((~немає).sum()),
дошка.loc[немає, "шахрайське"].mean() * 100, int(немає.sum())))
ціна : значення є — 12.6 % шахрайських (1100 рядків) · значення немає — 56.0 % (100 рядків) стан : значення є — 16.4 % шахрайських (1152 рядків) · значення немає — 12.5 % (48 рядків)
4 · Шлях другий: викинути стовпецьЯкщо порожньо в половині рядків, заповнювати нема чого — ти вигадуєш більше, ніж знаєш.Орієнтир: понад 50 % пропусків — колонка радше шкодить; менше 10 % — заповнюй спокійно.¶
частка_пропусків = (дошка.isna().mean() * 100).round(1)
print("частка пропусків у кожному стовпці, %:")
print(частка_пропусків[частка_пропусків > 0])
print("\nобидві колонки нижче 10 % — лишаємо обидві")
частка пропусків у кожному стовпці, %: стан 4.0 ціна 8.3 dtype: float64 обидві колонки нижче 10 % — лишаємо обидві
5 · Шлях третій: заповнити — і чим самеСереднє чи медіана? На скошеному розподілі це не косметичне питання. Подивімось,що кожен варіант робить із колонкою.¶
ціни = дошка["ціна"]
середнє = ціни.mean()
медіана = ціни.median()
print("середнє : %.1f грн" % середнє)
print("медіана : %.1f грн" % медіана)
print("дешевших за середнє: %d з %d = %.1f %%"
% ((ціни.dropna() < середнє).sum(), ціни.notna().sum(),
(ціни.dropna() < середнє).mean() * 100))
print()
# кошик шириною 1 250 грн — той самий, що на картинці в лекції
кошик = 1250
номер_середнього = int(середнє // кошик)
номер_медіани = int(медіана // кошик)
у_кошику = ціни.dropna().floordiv(кошик).astype(int).value_counts()
print("у кошик середнього (%d–%d грн) впаде 100 вигаданих цін до %d справжніх"
% (номер_середнього * кошик, (номер_середнього + 1) * кошик, у_кошику[номер_середнього]))
print("у кошик медіани (%d–%d грн) — ті самі 100 до %d справжніх"
% (номер_медіани * кошик, (номер_медіани + 1) * кошик, у_кошику[номер_медіани]))
середнє : 6498.2 грн медіана : 4000.0 грн дешевших за середнє: 755 з 1100 = 68.6 % у кошик середнього (6250–7500 грн) впаде 100 вигаданих цін до 75 справжніх у кошик медіани (3750–5000 грн) — ті самі 100 до 148 справжніх
Заповнення середнім будує другий пік там, де справжніх оголошень утричі менше. Подивімосьна це очима.
межі = np.arange(0, 25001, 1250)
fig, осі = plt.subplots(1, 3, figsize=(14, 3.4), sharey=True)
для_показу = [("як є (100 дірок)", ціни.dropna()),
("заповнено середнім", ціни.fillna(середнє)),
("заповнено медіаною", ціни.fillna(медіана))]
for вісь, (назва, значення) in zip(осі, для_показу):
вісь.hist(значення[значення <= 25000], bins=межі, color="#c2185b", alpha=0.55,
edgecolor="#c2185b")
вісь.set_title(назва, fontsize=11)
вісь.set_xlabel("ціна, грн")
осі[0].set_ylabel("оголошень")
plt.tight_layout()
plt.show()
Для текстової колонки вибір інший: мода (найчастіше значення) або окрема категорія«невідомо». Мода робить найчастіше значення ще частішим — перевіримо, наскільки.
мода_стану = дошка["стан"].mode()[0]
було = (дошка["стан"] == мода_стану).sum() / дошка["стан"].notna().sum() * 100
стало = (дошка["стан"].fillna(мода_стану) == мода_стану).mean() * 100
print("найчастіший стан:", мода_стану)
print("його частка: %.1f %% → %.1f %% після заповнення модою" % (було, стало))
print()
print("варіант без вигадування — окрема категорія:")
print(дошка["стан"].fillna("невідомо").value_counts())
найчастіший стан: добре його частка: 41.9 % → 44.2 % після заповнення модою варіант без вигадування — окрема категорія: стан добре 483 дуже добре 364 задовільне 200 нове 105 невідомо 48 Name: count, dtype: int64
---# Пропуск як ознака## 6 · Прапорець, який коштує один рядокЗначення ціни втрачене назавжди. А от факт його відсутності лежить у таблиці — івідрізняє шахрая краще, ніж багато справжніх колонок. Записати його треба дозаповнення: після нього прапорець вийде з самих нулів.
дошка["ціни_немає"] = дошка["ціна"].isna().astype(int)
дошка["стану_немає"] = дошка["стан"].isna().astype(int)
for прапорець in ["ціни_немає", "стану_немає"]:
print("кореляція %-12s з таргетом: %+.3f"
% (прапорець, дошка[прапорець].corr(дошка["шахрайське"])))
print("\nдля порівняння — справжні числові колонки:")
for колонка in ["вік_акаунта", "рік", "памʼять_гб"]:
print("кореляція %-12s з таргетом: %+.3f"
% (колонка, дошка[колонка].corr(дошка["шахрайське"])))
кореляція ціни_немає з таргетом: +0.325 кореляція стану_немає з таргетом: -0.021 для порівняння — справжні числові колонки: кореляція вік_акаунта з таргетом: -0.114 кореляція рік з таргетом: +0.038 кореляція памʼять_гб з таргетом: +0.076
Прапорець ціни_немає повʼязаний із таргетом сильніше за будь-яку справжню числовуколонку в цій таблиці — і він не коштував нічого. Прапорець стану_немає не несе нічого,і це теж корисно знати: робити такі колонки на кожен стовпець без перевірки не варто.Тепер, коли інформація збережена, дірки можна закривати. Стан закриємо одразу — окремоюкатегорією «невідомо». А ось ціну поки лишимо як є, і на це є дві причини.Перша: усі статистики колонки, які ми рахуватимемо далі — середнє, розкид, квартилі,скошеність, — треба рахувати на справжніх значеннях. Сто однакових підставлених цінштучно звузили б розкид і зсунули б квартилі.Друга, важливіша: заповнення навчається на даних, а отже, його місце — усерединіконвеєра, після поділу на train і test. Саме це ми й побачимо в розділах 11 і 12.
дошка["стан"] = дошка["стан"].fillna("невідомо")
print("пропусків у стані не лишилось:", int(дошка["стан"].isna().sum()))
print("пропусків у ціні поки що :", int(дошка["ціна"].isna().sum()))
print(дошка.head(3))
пропусків у стані не лишилось: 0
пропусків у ціні поки що : 100
модель рік стан памʼять_гб вік_акаунта скарг ціна шахрайське ціни_немає стану_немає
0 Beta 12 Pro 2017 добре 256 186 0 3090.0 0 0 0
1 Alfa A7 2021 невідомо 128 394 0 2810.0 0 0 1
2 Gamma X 2017 нове 512 44 0 82000.0 0 0 0
---# Категорії## 7 · Пряме кодування проти порядковогоМодель приймає лише числа, тому «модель» і «стан» треба перевести в цифри. Спершу —пряме кодування (one-hot): колонка на кожне значення.
пряме = pd.get_dummies(дошка[["модель", "стан"]], prefix=["модель", "стан"]).astype(int)
print("було 2 текстові колонки, стало", пряме.shape[1])
print(list(пряме.columns))
print()
print(пряме.head(3))
print("\nу кожному рядку рівно одна одиниця на кожну вихідну колонку:",
bool((пряме.filter(like="модель_").sum(axis=1) == 1).all()))
було 2 текстові колонки, стало 11 ['модель_Alfa A5', 'модель_Alfa A7', 'модель_Beta 12', 'модель_Beta 12 Pro', 'модель_Gamma X', 'модель_Gamma X Ultra', 'стан_добре', 'стан_дуже добре', 'стан_задовільне', 'стан_невідомо', 'стан_нове'] модель_Alfa A5 модель_Alfa A7 модель_Beta 12 модель_Beta 12 Pro модель_Gamma X модель_Gamma X Ultra \ 0 0 0 0 1 0 0 1 0 1 0 0 0 0 2 0 0 0 0 1 0 стан_добре стан_дуже добре стан_задовільне стан_невідомо стан_нове 0 1 0 0 0 0 1 0 0 0 1 0 2 0 0 0 0 1 у кожному рядку рівно одна одиниця на кожну вихідну колонку: True
Те саме вміє OneHotEncoder зі scikit-learn. Переконаймось, що це буквально одне й те саме.
кодувальник = OneHotEncoder(sparse_output=False, dtype=int)
бібліотечне = кодувальник.fit_transform(дошка[["модель", "стан"]])
# порядок колонок у get_dummies і в OneHotEncoder однаковий — обидва сортують значення
assert np.array_equal(пряме.values, бібліотечне), "кодування розійшлось!"
print("✅ наше get_dummies і OneHotEncoder дають однакову матрицю",
бібліотечне.shape)
✅ наше get_dummies і OneHotEncoder дають однакову матрицю (1200, 11)
Тепер порядкове кодування. Для «стану» воно правильне: порядок «задовільне → нове»справжній. Для «моделі» — ні, і зараз ми побачимо, що саме воно стверджує.
порядок_стану = {"задовільне": 0, "добре": 1, "дуже добре": 2, "нове": 3}
дошка["стан_код"] = дошка["стан"].map(порядок_стану) # «невідомо» лишиться NaN — це чесно
медіанні_ціни = дошка.groupby("модель")["ціна"].median().sort_values()
код_моделі = {назва: номер for номер, назва in enumerate(медіанні_ціни.index)}
порівняння = pd.DataFrame({
"код": pd.Series(код_моделі),
"медіанна ціна": медіанні_ціни.round(0),
})
порівняння["крок коду"] = порівняння["код"].diff()
порівняння["крок ціни"] = порівняння["медіанна ціна"].diff()
print(порівняння)
код медіанна ціна крок коду крок ціни Alfa A5 0 1845.0 NaN NaN Alfa A7 1 2810.0 1.0 965.0 Beta 12 2 4215.0 1.0 1405.0 Beta 12 Pro 3 6290.0 1.0 2075.0 Gamma X 4 9350.0 1.0 3060.0 Gamma X Ultra 5 11540.0 1.0 2190.0
Крок коду завжди дорівнює одиниці. Крок ціни — від 965 до 3 060 грн. Тобто порядковекодування стверджує, що всі сусідні моделі однаково далекі одна від одної, і це неправда.Перевіримо найгучніше з його тверджень напряму.
# код каже, що Beta 12 (2) лежить рівно посередині між Alfa A5 (0) і Gamma X (4)
середина_за_кодом = (медіанні_ціни["Alfa A5"] + медіанні_ціни["Gamma X"]) / 2
насправді = медіанні_ціни["Beta 12"]
print("«середина» між Alfa A5 і Gamma X за цінами: %.0f грн" % середина_за_кодом)
print("справжня медіанна ціна Beta 12 : %.0f грн" % насправді)
print("розбіжність: %.0f грн, тобто %.0f %%"
% (середина_за_кодом - насправді, (середина_за_кодом / насправді - 1) * 100))
«середина» між Alfa A5 і Gamma X за цінами: 5598 грн справжня медіанна ціна Beta 12 : 4215 грн розбіжність: 1382 грн, тобто 33 %
---# Масштаб## 8 · Стандартизація й мінімакс своїми рукамиОбидва перетворення — одна формула кожне. Порахуємо їх руками й звіримо з бібліотекою:всередині scikit-learn немає ніякої магії.
# рядки з відомою ціною: масштаб колонки вимірюємо на справжніх значеннях
відомі = дошка[дошка["ціни_немає"] == 0]
числові = ["ціна", "вік_акаунта", "рік", "памʼять_гб"]
X = відомі[числові].to_numpy(dtype=float)
print("рядків із відомою ціною:", len(X))
# стандартизація: відняти середнє колонки й поділити на її стандартне відхилення
наша_стандартизація = (X - X.mean(axis=0)) / X.std(axis=0)
бібліотечна = StandardScaler().fit_transform(X)
assert np.allclose(наша_стандартизація, бібліотечна), "стандартизація розійшлась!"
# мінімакс: відняти мінімум і поділити на розмах — колонка лягає у відрізок [0, 1]
наш_мінімакс = (X - X.min(axis=0)) / (X.max(axis=0) - X.min(axis=0))
бібліотечний = MinMaxScaler().fit_transform(X)
assert np.allclose(наш_мінімакс, бібліотечний), "мінімакс розійшовся!"
print("✅ обидва перетворення збігаються з scikit-learn")
print()
зведення = pd.DataFrame({
"середнє, як є": X.mean(axis=0).round(1),
"розкид, як є": X.std(axis=0).round(1),
"середнє після стандартизації": наша_стандартизація.mean(axis=0).round(6),
"розкид після стандартизації": наша_стандартизація.std(axis=0).round(6),
"мінімум після мінімаксу": наш_мінімакс.min(axis=0),
"максимум після мінімаксу": наш_мінімакс.max(axis=0),
}, index=числові)
print(зведення)
рядків із відомою ціною: 1100
✅ обидва перетворення збігаються з scikit-learn
середнє, як є розкид, як є середнє після стандартизації розкид після стандартизації \
ціна 6498.2 8685.0 0.0 1.0
вік_акаунта 404.7 406.6 0.0 1.0
рік 2020.5 2.3 -0.0 1.0
памʼять_гб 169.5 123.7 0.0 1.0
мінімум після мінімаксу максимум після мінімаксу
ціна 0.0 1.0
вік_акаунта 0.0 1.0
рік 0.0 1.0
памʼять_гб 0.0 1.0
9 · Масштаб змінює відповідь, а не картинкуТри справжні оголошення з дошки. Питання просте: яке з двох ближче до базового?¶
трійка = pd.DataFrame(
[[4620.0, 459], [4650.0, 273], [5480.0, 466]],
index=["базове", "А", "Б"], columns=["ціна", "вік_акаунта"])
print(трійка)
# статистики — по рядках із відомою ціною, тобто по справжніх значеннях колонки
середні = відомі[["ціна", "вік_акаунта"]].mean().to_numpy()
розкиди = відомі[["ціна", "вік_акаунта"]].std(ddof=0).to_numpy()
мінімуми = відомі[["ціна", "вік_акаунта"]].min().to_numpy()
максимуми = відомі[["ціна", "вік_акаунта"]].max().to_numpy()
def відстані(значення):
'''Відстань від базового оголошення до А і до Б за теоремою Піфагора.'''
база = значення[0]
return np.sqrt(((значення[1:] - база) ** 2).sum(axis=1))
сирі = трійка.to_numpy()
режими = {
"як є": сирі,
"стандартизація": (сирі - середні) / розкиди,
"мінімакс": (сирі - мінімуми) / (максимуми - мінімуми),
}
for назва, значення in режими.items():
до_А, до_Б = відстані(значення)
print("%-15s до А = %9.4f · до Б = %9.4f · ближче: %s"
% (назва, до_А, до_Б, "А" if до_А < до_Б else "Б"))
ціна вік_акаунта базове 4620.0 459 А 4650.0 273 Б 5480.0 466 як є до А = 188.4038 · до Б = 860.0285 · ближче: А стандартизація до А = 0.4575 · до Б = 0.1005 · ближче: Б мінімакс до А = 0.0529 · до Б = 0.0093 · ближче: Б
Сирі одиниці кажуть «А», обидва масштабування — «Б». Причина видно на числах: різницяв 860 грн — це лише 0.10 розкиду цін, а різниця в 186 днів — 0.46 розкиду віку акаунтів.Порівнювати гривні з днями безглуздо, тому перша відповідь просто не має сенсу.
різниця_ціни = 5480 - 4620
різниця_віку = 459 - 273
print("860 грн = %.2f розкиду цін (розкид %.0f грн)" % (різниця_ціни / розкиди[0], розкиди[0]))
print("186 днів = %.2f розкиду віку (розкид %.0f днів)" % (різниця_віку / розкиди[1], розкиди[1]))
print()
частка_низько = ((відомі["ціна"] - мінімуми[0]) / (максимуми[0] - мінімуми[0]) < 0.1).mean()
print("мінімакс: %.1f %% усіх цін лежать нижче 0.1 — шкалу зʼїв колекційний телефон за 95 000 грн"
% (частка_низько * 100))
860 грн = 0.10 розкиду цін (розкид 8685 грн) 186 днів = 0.46 розкиду віку (розкид 407 днів) мінімакс: 81.7 % усіх цін лежать нижче 0.1 — шкалу зʼїв колекційний телефон за 95 000 грн
---# Викиди## 10 · Обрізати чи логарифмуватиВикиди ми знайшли ще в темі 08. Тепер вирішуємо, що з ними робити. Порахуємо межуміжквартильного розмаху й порівняємо три варіанти: лишити, обрізати, логарифмувати.
ціни_відомі = дошка["ціна"].dropna()
q1, q3 = ціни_відомі.quantile([0.25, 0.75])
розмах = q3 - q1
верхня_межа = q3 + 1.5 * розмах
за_межею = (ціни_відомі > верхня_межа).sum()
print("Q1 = %.0f · Q3 = %.0f · IQR = %.0f · верхня межа = %.0f грн"
% (q1, q3, розмах, верхня_межа))
print("за межею: %d оголошень = %.1f %% колонки"
% (за_межею, за_межею / len(ціни_відомі) * 100))
def скошеність(значення):
'''Наскільки хвіст витягнутий праворуч. Нуль — симетрія.'''
з = np.asarray(значення, dtype=float)
return float((((з - з.mean()) / з.std()) ** 3).mean())
обрізана = ціни_відомі.clip(upper=верхня_межа)
логарифм = np.log10(ціни_відомі)
print()
print("%-16s середнє %8.0f · максимум %8.0f · скошеність %6.2f"
% ("як є", ціни_відомі.mean(), ціни_відомі.max(), скошеність(ціни_відомі)))
print("%-16s середнє %8.0f · максимум %8.0f · скошеність %6.2f"
% ("обрізано", обрізана.mean(), обрізана.max(), скошеність(обрізана)))
print("%-16s середнє %8.3f · максимум %8.3f · скошеність %6.2f"
% ("логарифм", логарифм.mean(), логарифм.max(), скошеність(логарифм)))
print()
print("середнє логарифмів назад у гривні: %.0f грн (медіана колонки: %.0f грн)"
% (10 ** логарифм.mean(), ціни_відомі.median()))
Q1 = 2168 · Q3 = 7725 · IQR = 5558 · верхня межа = 16061 грн за межею: 75 оголошень = 6.8 % колонки як є середнє 6498 · максимум 95000 · скошеність 5.32 обрізано середнє 5557 · максимум 16061 · скошеність 1.10 логарифм середнє 3.610 · максимум 4.978 · скошеність 0.10 середнє логарифмів назад у гривні: 4075 грн (медіана колонки: 4000 грн)
fig, осі = plt.subplots(1, 2, figsize=(11, 3.4))
осі[0].hist(ціни_відомі[ціни_відомі <= 25000], bins=20, color="#c2185b", alpha=0.55,
edgecolor="#c2185b")
осі[0].set_title("ціна як є (вісь обрізана)", fontsize=11)
осі[0].set_xlabel("ціна, грн")
осі[1].hist(логарифм, bins=24, color="#0f766e", alpha=0.55, edgecolor="#0f766e")
осі[1].set_title("log10(ціна) — уся колонка", fontsize=11)
осі[1].set_xlabel("log10(ціна)")
осі[0].set_ylabel("оголошень")
plt.tight_layout()
plt.show()
---# Витік через передобробку## 11 · Задача, у якій немає жодної моделіПравило: оголошення підозріле, якщо його ціна нижча за поріг П₁ або вища за поріг П₂.З теми 08 ми знаємо, що звʼязок ціни з шахрайством U-подібний, тому пороги саме два.Вигадувати їх ми не будемо — підберемо за даними, окремо для кожної пари«модель + стан»: для Alfa A5 і для Gamma X Ultra «дешево» означає різні суми.Це і є той крок, який навчається. І зараз ми порівняємо два порядки дій.
НИЖНІ = np.arange(0.02, 0.41, 0.02) # 20 кандидатів на нижній поріг
ВЕРХНІ = np.arange(0.60, 0.99, 0.02) # 20 кандидатів на верхній
def підібрати_пороги(ціни, мітки):
'''Перебирає всі 400 пар квантилів і повертає ту, що дає найбільший F1.'''
низ = np.quantile(ціни, НИЖНІ)
верх = np.quantile(ціни, ВЕРХНІ)
# підозра[i, j, k] — чи позначає k-те оголошення пара порогів (низ[i], верх[j])
підозра = (ціни[None, None, :] < низ[:, None, None]) | (ціни[None, None, :] > верх[None, :, None])
tp = (підозра & (мітки == 1)).sum(axis=2)
fp = (підозра & (мітки == 0)).sum(axis=2)
fn = int((мітки == 1).sum()) - tp
f1 = np.where(tp > 0, 2 * tp / np.maximum(2 * tp + fp + fn, 1), 0.0)
i, j = np.unravel_index(np.argmax(f1), f1.shape)
return низ[i], верх[j]
def навчити_пороги(ціни, мітки, групи):
'''Своя пара порогів для кожної групи «модель + стан».'''
return {група: підібрати_пороги(ціни[групи == група], мітки[групи == група])
for група in np.unique(групи)}
def застосувати_пороги(ціни, групи, пороги, запасні):
позначено = np.zeros(len(ціни), dtype=int)
for група in np.unique(групи):
низ, верх = пороги.get(група, запасні) # незнайомій групі даємо спільні пороги
у_групі = групи == група
позначено[у_групі] = ((ціни[у_групі] < низ) | (ціни[у_групі] > верх)).astype(int)
return позначено
групи = (дошка["модель"] + " · " + дошка["стан"]).to_numpy()
print("груп «модель + стан»:", len(np.unique(групи)))
print("порогів, які треба вивчити:", len(np.unique(групи)) * 2)
print("найменша група:", pd.Series(групи).value_counts().min(), "рядків")
груп «модель + стан»: 30 порогів, які треба вивчити: 60 найменша група: 5 рядків
Тепер повертаємось до сирої ціни (з дірками) — бо заповнення теж навчається, і йоготеж треба буде порахувати двома способами.
сира_ціна = дошка["ціна"].to_numpy(dtype=float) # сто дірок так і лишились на місці
таргет = дошка["шахрайське"].to_numpy()
навчальні, тестові = train_test_split(
np.arange(len(дошка)), test_size=0.25, random_state=42, stratify=таргет)
print("train:", len(навчальні), "рядків · test:", len(тестові), "рядків")
print("шахрайських у тесті:", int(таргет[тестові].sum()))
train: 900 рядків · test: 300 рядків шахрайських у тесті: 49
Неправильно: спершу порахували на всьому, потім поділили¶
# медіана для заповнення береться з УСІЄЇ таблиці — включно з тестовими рядками
медіана_на_всьому = np.nanmedian(сира_ціна)
ціна_на_всьому = np.where(np.isnan(сира_ціна), медіана_на_всьому, сира_ціна)
запасні_на_всьому = підібрати_пороги(ціна_на_всьому, таргет)
пороги_на_всьому = навчити_пороги(ціна_на_всьому, таргет, групи)
позначено = застосувати_пороги(ціна_на_всьому[тестові], групи[тестові],
пороги_на_всьому, запасні_на_всьому)
неправильно = (precision_score(таргет[тестові], позначено),
recall_score(таргет[тестові], позначено),
f1_score(таргет[тестові], позначено))
print("медіана заповнення: %.0f грн" % медіана_на_всьому)
print("precision %.3f · recall %.3f · F1 %.3f" % неправильно)
медіана заповнення: 4000 грн precision 0.571 · recall 0.735 · F1 0.643
Правильно: спершу поділили, потім вивчили — лише на train¶
медіана_на_train = np.nanmedian(сира_ціна[навчальні])
ціна_train = np.where(np.isnan(сира_ціна[навчальні]), медіана_на_train, сира_ціна[навчальні])
ціна_test = np.where(np.isnan(сира_ціна[тестові]), медіана_на_train, сира_ціна[тестові])
запасні_train = підібрати_пороги(ціна_train, таргет[навчальні])
пороги_train = навчити_пороги(ціна_train, таргет[навчальні], групи[навчальні])
позначено = застосувати_пороги(ціна_test, групи[тестові], пороги_train, запасні_train)
правильно = (precision_score(таргет[тестові], позначено),
recall_score(таргет[тестові], позначено),
f1_score(таргет[тестові], позначено))
print("медіана заповнення: %.0f грн" % медіана_на_train)
print("precision %.3f · recall %.3f · F1 %.3f" % правильно)
print()
print("завищення F1: %+.3f — це %.0f %% від чесної оцінки"
% (неправильно[2] - правильно[2],
(неправильно[2] - правильно[2]) / правильно[2] * 100))
медіана заповнення: 3975 грн precision 0.418 · recall 0.571 · F1 0.483 завищення F1: +0.160 — це 33 % від чесної оцінки
Різниця — третина оцінки, і вона взялась із двох дрібниць: медіана 4 000 замість 3 975 і60 порогів, підібраних із заглядуванням у тест. Один поділ міг бути й невдалим збігом,тому перевіримо на двадцяти.
завищення = []
for зерно in range(20):
a, b = train_test_split(np.arange(len(дошка)), test_size=0.25,
random_state=зерно, stratify=таргет)
# неправильний порядок
ц = np.where(np.isnan(сира_ціна), np.nanmedian(сира_ціна), сира_ціна)
п = навчити_пороги(ц, таргет, групи)
з = підібрати_пороги(ц, таргет)
погано = f1_score(таргет[b], застосувати_пороги(ц[b], групи[b], п, з))
# правильний порядок
м = np.nanmedian(сира_ціна[a])
ц_a = np.where(np.isnan(сира_ціна[a]), м, сира_ціна[a])
ц_b = np.where(np.isnan(сира_ціна[b]), м, сира_ціна[b])
п2 = навчити_пороги(ц_a, таргет[a], групи[a])
з2 = підібрати_пороги(ц_a, таргет[a])
добре = f1_score(таргет[b], застосувати_пороги(ц_b, групи[b], п2, з2))
завищення.append(погано - добре)
завищення = np.array(завищення)
print("середнє завищення F1 по 20 поділах: %+.3f" % завищення.mean())
print("оцінку завищено у %d поділах із 20" % int((завищення > 0).sum()))
print("найменше %+.3f · найбільше %+.3f" % (завищення.min(), завищення.max()))
середнє завищення F1 по 20 поділах: +0.149 оцінку завищено у 20 поділах із 20 найменше +0.086 · найбільше +0.298
Завищення не просто велике — воно завжди в один бік. Це і робить помилку такоюпідступною: вона ніколи не виглядає як помилка.---# Конвеєр## 12 · Об'єкт, який не дає помилитисьТримати порядок дій у голові на десяти колонках і чотирьох перетвореннях безнадійно.Pipeline разом із ColumnTransformer складають усі кроки в один ланцюжок із двомаметодами: fit — «вивчи все, що треба вивчити», transform — «застосуй вивчене».
вихідні = дошка[["ціна", "модель", "стан", "рік",
"памʼять_гб", "вік_акаунта", "ціни_немає"]].copy()
числові_колонки = ["ціна", "вік_акаунта", "рік", "памʼять_гб", "ціни_немає"]
текстові_колонки = ["модель", "стан"]
конвеєр = ColumnTransformer([
("числа", Pipeline([
("заповнити", SimpleImputer(strategy="median")),
("масштаб", StandardScaler()),
]), числові_колонки),
("текст", Pipeline([
("заповнити", SimpleImputer(strategy="constant", fill_value="невідомо")),
("однобітно", OneHotEncoder(handle_unknown="ignore")),
]), текстові_колонки),
])
# fit бачить ЛИШЕ навчальні рядки — це вся суть
конвеєр.fit(вихідні.iloc[навчальні])
train_готовий = конвеєр.transform(вихідні.iloc[навчальні])
test_готовий = конвеєр.transform(вихідні.iloc[тестові])
print("колонок на вході:", вихідні.shape[1], "· на виході:", train_готовий.shape[1])
print("train:", train_готовий.shape, "· test:", test_готовий.shape)
колонок на вході: 7 · на виході: 16 train: (900, 16) · test: (300, 16)
Найцікавіше — подивитись, що саме конвеєр вивчив і на чому.
заповнювач = конвеєр.named_transformers_["числа"].named_steps["заповнити"]
масштаб = конвеєр.named_transformers_["числа"].named_steps["масштаб"]
print("медіана ціни, яку запамʼятав конвеєр: %.0f грн" % заповнювач.statistics_[0])
print("медіана ціни по всій таблиці : %.0f грн" % np.nanmedian(сира_ціна))
print("→ конвеєр узяв навчальну, а не загальну")
print()
print("середнє й відхилення ціни, вивчені конвеєром: %.1f · %.1f"
% (масштаб.mean_[0], np.sqrt(масштаб.var_[0])))
print()
print("ціна після масштабування:")
print(" на train: середнє %+.4f, відхилення %.4f ← рівно 0 і 1, бо саме тут учились"
% (train_готовий[:, 0].mean(), train_готовий[:, 0].std()))
print(" на test : середнє %+.4f, відхилення %.4f ← не рівно, і це доказ чесності"
% (test_готовий[:, 0].mean(), test_готовий[:, 0].std()))
медіана ціни, яку запамʼятав конвеєр: 3975 грн медіана ціни по всій таблиці : 4000 грн → конвеєр узяв навчальну, а не загальну середнє й відхилення ціни, вивчені конвеєром: 6130.6 · 8193.7 ціна після масштабування: на train: середнє -0.0000, відхилення 1.0000 ← рівно 0 і 1, бо саме тут учились на test : середнє +0.0768, відхилення 1.0697 ← не рівно, і це доказ чесності
Останній рядок — найкорисніший у всьому зошиті. Якби середнє тестової частини вийшлорівно нулем, це означало б, що масштабувальник її бачив. Воно не нуль — отже, тестсправді лишився майбутнім, якого модель ще не бачила.Перевіримо це ще й прямою рівністю: конвеєр має давати те саме, що ми порахували руками.
наше_заповнення = np.where(np.isnan(сира_ціна[тестові]), медіана_на_train, сира_ціна[тестові])
наше_масштабування = (наше_заповнення - масштаб.mean_[0]) / np.sqrt(масштаб.var_[0])
assert np.allclose(наше_масштабування, test_готовий[:, 0]), "конвеєр робить не те, що ми думали!"
print("✅ ручний розрахунок і конвеєр збігаються на всіх", len(тестові), "тестових рядках")
✅ ручний розрахунок і конвеєр збігаються на всіх 300 тестових рядках
---# Завдання## 🟢 Рівень 1 — База1. Заповни пропуски ціни середнім замість медіани й повтори розділ 11 (витік). Чи змінилась різниця між правильним і неправильним порядком?2. Побудуй прапорець памʼяті_немає для колонки памʼяті (там пропусків немає — має вийти колонка з самих нулів). Порахуй її кореляцію з таргетом і поясни отримане число.3. Порахуй, скільки колонок дасть пряме кодування, якщо додати до нього ще й рік як категорію. Чи варто так робити?## 🟡 Рівень 2 — Плюс4. Заміни в конвеєрі StandardScaler на MinMaxScaler і подивись на діапазон тестової частини після transform. Чи всі значення лежать у відрізку [0, 1]? Поясни, чому.5. Додай у конвеєр окремий крок, який логарифмує ціну до масштабування (FunctionTransformer(np.log1p)). Порівняй скошеність колонки до й після.6. Повтори розділ 11, але з порогами на модель (6 груп, 12 чисел) замість «модель + стан» (30 груп, 60 чисел). Наскільки менше стало завищення?## 🔴 Рівень 3 — Виклик7. Напиши власний клас Заповнювач із методами fit(X) і transform(X), який запамʼятовує медіану кожної числової колонки і підставляє її. Звір результат із SimpleImputer(strategy="median") через assert np.allclose(...).8. Побудуй графік: по горизонталі — скільки чисел вивчається з даних (2, 12, 60), по вертикалі — середнє завищення F1 по 20 поділах. Чи росте залежність? Додай четверту точку: пороги на кожну пару «модель + рік».9. Придумай і перевір на цих даних ситуацію, у якій видалення рядків із пропусками краще за заповнення. Підказка: подивись, що станеться з precision, якщо в тесті лишити тільки рядки з відомою ціною.